package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // NardyModel — TUI поверх NardyGameState. Ходы выбираются из // пронумерованного списка легальных ходов (а не курсором по доске) // — так же, как уже сделано для боевых действий Странника и // действий Трупных батончиков: в нардах набор легальных ходов после // броска костей обычно невелик (как правило до 8-9), и пронумерованный // список полностью снимает вопрос двумерной навигации по доске. type NardyModel struct { game *NardyGameState bot NardyBot rng *rand.Rand humanColor NardyPlayer message string isError bool // choosingDie / pendingSourceMoves — подрежим выбора кости, когда // у выбранного источника больше одного варианта хода (см. // nardySourceGroups). Разделение на два шага нужно, потому что // плоский список всех легальных ходов в мидгейме коротких нард // может заметно превышать 9 пунктов, а различных ИСТОЧНИКОВ хода // (с какой шашки) на практике почти никогда не бывает больше 8. choosingDie bool pendingSourceMoves []NardyMove quitting bool backToMenu bool } // NewNardyModel начинает новую партию. Кто ходит первым — решается // случайным броском одного зара каждой стороне (по правилам, у кого // больше — тот и первый); при равенстве — переброс, как и // предписывают правила. Человек всегда играет белыми в этой версии // (цвет — условность отображения, на баланс сил не влияет). func NewNardyModel(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) NardyModel { rng := rand.New(rand.NewSource(time.Now().UnixNano())) humanColor := resolveNardyColor(colorChoice, rng) firstPlayer := NardyWhite for { human, bot := rng.Intn(6)+1, rng.Intn(6)+1 if human != bot { if human > bot { firstPlayer = humanColor } else { firstPlayer = nardyOpponent(humanColor) } break } } return NardyModel{ game: NewNardyGame(variant, firstPlayer), bot: NardyBot{Difficulty: difficulty}, rng: rng, humanColor: humanColor, } } // resolveNardyColor переводит выбор цвета из настроек в конкретный // NardyPlayer, используя уже созданный генератор модели для // случайного выбора (в отличие от Шашек/Шахмат/Го, у Нард и так уже // есть постоянный rng для бросков костей). func resolveNardyColor(choice ColorChoice, rng *rand.Rand) NardyPlayer { switch choice { case ColorChoiceA: return NardyWhite case ColorChoiceB: return NardyBlack default: if rng.Intn(2) == 0 { return NardyWhite } return NardyBlack } } func (m NardyModel) Init() tea.Cmd { return m.maybeScheduleBot() } func (m NardyModel) maybeScheduleBot() tea.Cmd { if m.game.Phase == NardyPhaseOver { return nil } if m.game.CurrentPlayer == m.humanColor { return nil } return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) } func (m *NardyModel) setInfo(key string) { m.message = T(key) m.isError = false } func (m *NardyModel) setError(err error) { m.message = err.Error() m.isError = true } func (m NardyModel) 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 NardyModel) handleBotMove() (tea.Model, tea.Cmd) { if m.game.Phase == NardyPhaseOver || m.game.CurrentPlayer == m.humanColor { return m, nil } if err := m.bot.PlayFullTurn(m.game, m.rng); err != nil { m.setError(err) return m, nil } m.setInfo("nardy.msg.bot_moved") return m, m.maybeScheduleBot() } // nardySourceGroups группирует легальные ходы по источнику (с бара // или с конкретной точки) — на практике различных источников редко // бывает больше 8, даже когда общий список ходов (по всем костям и // точкам вместе) заметно превышает 9. func nardySourceGroups(moves []NardyMove) [][]NardyMove { var order []string groups := map[string][]NardyMove{} for _, mv := range moves { key := fmt.Sprintf("%v-%d", mv.FromBar, mv.From) if _, ok := groups[key]; !ok { order = append(order, key) } groups[key] = append(groups[key], mv) } out := make([][]NardyMove, 0, len(order)) for _, key := range order { out = append(out, groups[key]) } return out } func nardySourceLabel(mv NardyMove) string { if mv.FromBar { return T("nardy.source_bar") } return fmt.Sprintf("%d", mv.From+1) } func (m NardyModel) 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 == NardyPhaseOver || m.game.CurrentPlayer != m.humanColor { return m, nil } if m.choosingDie { if key == "esc" { m.choosingDie = false m.pendingSourceMoves = nil return m, nil } for i := 1; i <= 9; i++ { if key != fmt.Sprintf("%d", i) || i > len(m.pendingSourceMoves) { continue } mv := m.pendingSourceMoves[i-1] m.choosingDie = false m.pendingSourceMoves = nil return m.applyHumanMove(mv) } return m, nil } if m.game.Phase == NardyPhaseRoll { if key == "r" || key == "enter" || key == " " { m.game.Roll(m.rng.Intn(6)+1, m.rng.Intn(6)+1) if m.game.CurrentPlayer != m.humanColor { m.setInfo("nardy.msg.no_legal_moves") return m, m.maybeScheduleBot() } m.message = "" return m, nil } return m, nil } // NardyPhaseMove: выбор источника хода (шаг 1 из потенциально двух) groups := nardySourceGroups(m.game.LegalMovesNow()) for i := 1; i <= 9; i++ { if key != fmt.Sprintf("%d", i) || i > len(groups) { continue } group := groups[i-1] if len(group) == 1 { return m.applyHumanMove(group[0]) } m.choosingDie = true m.pendingSourceMoves = group return m, nil } return m, nil } // applyHumanMove применяет один выбранный ход человека и планирует // следующий шаг (ход бота, если дошла его очередь, либо ожидание // следующего выбора). func (m NardyModel) applyHumanMove(mv NardyMove) (tea.Model, tea.Cmd) { if err := m.game.ApplyMove(mv); err != nil { m.setError(err) return m, nil } m.message = "" if m.game.Phase == NardyPhaseOver { m.setInfo("nardy.msg.game_over") return m, nil } return m, m.maybeScheduleBot() } // nardyPlayerSymbol — короткое обозначение цвета для отображения. func nardyPlayerSymbol(p NardyPlayer) string { if p == NardyWhite { return "○" } return "●" } // nardyMoveLabel формирует человекочитаемое описание одного хода в // традиционной нотации нард (точки нумеруются 1-24 от дома игрока, // а не 0-23 как во внутреннем представлении). func nardyMoveLabel(mv NardyMove) string { from := "с бара" var dest string if mv.FromBar { entryRel := 24 - mv.Die dest = fmt.Sprintf("%d", entryRel+1) } else { from = fmt.Sprintf("%d", mv.From+1) if mv.BearOff { dest = "снятие" } else { dest = fmt.Sprintf("%d", mv.From-mv.Die+1) } } return Tf("nardy.move_label", from, dest, mv.Die) } func (m NardyModel) View() string { if m.quitting { return T("common.goodbye") } var b strings.Builder title := T("nardy.title.short") if m.game.Variant == NardyLong { title = T("nardy.title.long") } fmt.Fprintln(&b, titleStyle.Render(title)) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderBoard()) fmt.Fprintln(&b) fmt.Fprintln(&b, Tf("nardy.bar_off_line", nardyPlayerSymbol(NardyWhite), m.game.Bar[NardyWhite], nardyPlayerSymbol(NardyBlack), m.game.Bar[NardyBlack], nardyPlayerSymbol(NardyWhite), m.game.BorneOff[NardyWhite], nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack])) fmt.Fprintln(&b) you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor)) fmt.Fprintln(&b, dimStyle.Render(you)) switch m.game.Phase { case NardyPhaseOver: if m.game.Result.Winner == m.humanColor { fmt.Fprintln(&b, winStyle.Render(T("nardy.outcome.win"))) } else { fmt.Fprintln(&b, errorStyle.Render(T("nardy.outcome.lose"))) } case NardyPhaseRoll: if m.game.CurrentPlayer == m.humanColor { fmt.Fprintln(&b, T("nardy.hint.roll")) } else { fmt.Fprintln(&b, T("common.thinking_generic")) } case NardyPhaseMove: fmt.Fprintln(&b, Tf("nardy.dice_line", m.game.Dice[0], m.game.Dice[1], strings.Trim(fmt.Sprint(m.game.DiceLeft), "[]"))) if m.game.CurrentPlayer == m.humanColor { if m.choosingDie { fmt.Fprintln(&b, T("nardy.hint.pick_die")) for i, mv := range m.pendingSourceMoves { if i >= 9 { break } fmt.Fprintf(&b, "[%d] %s\n", i+1, nardyMoveLabel(mv)) } fmt.Fprintln(&b, dimStyle.Render(T("nardy.hint.cancel_source"))) } else { fmt.Fprintln(&b, T("nardy.hint.pick_source")) groups := nardySourceGroups(m.game.LegalMovesNow()) for i, group := range groups { if i >= 9 { break } fmt.Fprintf(&b, "[%d] %s\n", i+1, nardySourceLabel(group[0])) } } } else { 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("nardy.hint.quit"))) return b.String() } // renderBoard рисует компактное текстовое представление доски: две // строки по 12 точек, всегда с точки зрения играющего за белых // (традиционная нумерация 1-24) — единая система отсчёта на экране // независимо от того, за кого в этой партии играет человек. func (m NardyModel) renderBoard() string { var b strings.Builder cell := func(abs int) string { pt := m.game.Board[abs] if pt.Count == 0 { return dimStyle.Render(" . ") } return fmt.Sprintf("%s%-2d", nardyPlayerSymbol(pt.Owner), pt.Count) } var top []string for abs := 12; abs <= 23; abs++ { top = append(top, cell(abs)) } fmt.Fprintln(&b, "13 14 15 16 17 18 19 20 21 22 23 24") fmt.Fprintln(&b, strings.Join(top, " ")) fmt.Fprintln(&b) var bottom []string for abs := 11; abs >= 0; abs-- { bottom = append(bottom, cell(abs)) } fmt.Fprintln(&b, strings.Join(bottom, " ")) fmt.Fprint(&b, "12 11 10 9 8 7 6 5 4 3 2 1") return b.String() }