diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..b541439 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +ggc +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ba8ba5 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Go Games Collection + +Коллекция консольных карточных игр и настольных игр на Go с текстовым интерфейсом (TUI) на [Bubble Tea](https://github.com/charmbracelet/bubbletea). Играете против ботов (кроме пасьянса — он на одного) прямо в терминале. + +``` + ____ ___ ____ _ __ __ _____ ____ + / ___|/ _ \ / ___| / \ | \/ | ____/ ___| +| | _| | | | | | _ / _ \ | |\/| | _| \___ \ +| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) | + \____|\___/ \____/_/ \_\_| |_|_____|____/ + ____ ___ _ _ _____ ____ _____ ___ ___ _ _ + / ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | | +| | | | | | | | | | _|| | | | | | | | | \| | +| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ | + \____\___/|_____|_____|_____\____| |_| |___\___/|_| \_| +``` + +## Игры в коллекции + +| Игра | Игроков | Особенности | +|---|---|---| +| **Тонк (Tonk)** | 1 человек + 3 бота | Денежная система: капитал, ставка, банкротство и замена бота | +| **Дурак (Durak)** | 2–6 | Полноценное подкидывание любым игроком (не только атакующим) | +| **Блэкджек (Blackjack)** | 1–6 (можно соло) | Каждый играет против дилера отдельно, hit/stand/double | +| **101** | 2–6 | Избавься от карт первым; выбывание по достижении 101 очка | +| **1000 (Тысяча)** | строго 3 | Торги, прикуп, взятки и марьяжи с динамическим козырем | +| **Косынка (Klondike)** | 1 (без ботов) | Классический пасьянс с отменой хода | +| **Шашки (Checkers)** | 1 человек + бот | Русские шашки, бот на минимаксе с 2 уровнями сложности | + +Каждая игра — со своим набором специально подобранных упрощений там, где у оригинала слишком много вариантов правил (например, «1000» реализована только на троих, а Дурак — без «тузового марьяжа» и бочки). Полный список правил и упрощений — в разделе «Правила» самого приложения или через `--help`. + +## Запуск + +Требуется Go 1.22+. + +```bash +go run . # открыть меню коллекции +go run . --help # показать правила всех игр, не заходя в TUI +go run . -sim # headless-симуляция ботов Тонка (для проверки баланса) +go run . -sim-durak # то же для Дурака +go run . -sim-blackjack # то же для Блэкджека +go run . -sim-101 # то же для 101 +go run . -sim-1000 # то же для Тысячи +``` + +Или собрать бинарник: + +```bash +go build -o games . +./games +``` + +## Общее управление в меню + +- `↑↓` / `k j` — навигация по пунктам меню +- `enter` — выбрать / подтвердить +- `esc` / `q` — назад +- Внутри игры `q` всегда возвращает в меню (не закрывает программу); `ctrl+c` закрывает приложение целиком + +У части игр перед стартом есть экран настроек: ставка и капитал (Тонк), число игроков (Дурак, 101), число игроков + ставка (Блэкджек), уровень сложности бота (Шашки). + +Управление в конкретной игре не дублируется здесь — оно всегда показано внизу экрана самой игры, а также доступно целиком через пункт «Правила» в меню или `--help`. + +## Структура проекта + +Каждая игра — самостоятельный набор из 3 файлов (движок / бот / TUI) плюс тесты; общая для всех инфраструктура (меню, карты, колода) вынесена отдельно. + +``` +card.go, deck.go — общие типы карт и стандартная колода на 52 карты +menu.go, banner.go — главное меню, ASCII-баннер, экраны настроек игр +rules.go — тексты правил всех игр (для TUI и --help) +main.go — точка входа, флаги -sim-*, --help + +game.go, bot.go, — Тонк: движок, боты трёх уровней сложности, +bot_heuristics.go, банкротство и замена бота, TUI +tui.go, player.go, +meld.go + +durak_*.go — Дурак: движок с полным подкидыванием, бот, TUI + +blackjack_*.go — Блэкджек: движок дилера, бот, TUI + +oneohone_*.go — 101: движок, бот, TUI + +thousand_*.go — 1000: движок торгов/взяток/марьяжей, бот, TUI + +klondike_*.go — Косынка: движок пасьянса с отменой хода, TUI + (ботов нет — играть некому, кроме человека) + +checkers_*.go — Шашки: движок русских шашек, бот на минимаксе, TUI + +*_test.go — тесты (юнит-тесты правил + стресс-симуляции + полных партий ботами) +``` + +## Тестирование + +```bash +go test ./... # весь пакет +go test ./... -v # подробный вывод по каждому тесту +``` + +В пакете 215 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой или марьяжа на первой взятке) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте. + +## Известные упрощения + +У традиционных карточных игр обычно много региональных вариантов правил. Там, где выбор был сделан осознанно (а не по ошибке), это явно прокомментировано в соответствующем `*_game.go` и продублировано в тексте правил игры. Коротко: + +- **Дурак** — полные правила (подкидывать может любой игрок, не только атакующий). +- **101** — туз пропускает ход, 6/7/пиковый король дают штрафные карты; десятка не меняет направление хода. +- **1000** — только на 3 игроков; нет бочки, золотого кона, самосвала, тузового марьяжа и игры втёмную. +- **Косынка** — добор по одной карте, неограниченные пересдачи; возврата карт с фундамента в игру нет. +- **Шашки** — русские правила (взятие в любом направлении простой шашкой, «летающая» дамка), без официальных 15/30-ходовых правил ничьей — вместо них простой лимит в 60 полуходов без взятий. diff --git a/bankruptcy_test.go b/bankruptcy_test.go new file mode 100644 index 0000000..fa5208a --- /dev/null +++ b/bankruptcy_test.go @@ -0,0 +1,100 @@ +package main + +import "testing" + +func TestCheckBankruptciesReplacesBot(t *testing.T) { + m := NewModel() + // делаем бота-1 банкротом + m.game.Players[1].Balance = 0 + oldName := m.game.Players[1].Name + + msg := m.checkBankruptcies() + if msg == "" { + t.Fatalf("ожидалось сообщение о банкротстве") + } + if m.game.Players[1].Name == oldName { + t.Errorf("имя обанкротившегося бота должно было смениться") + } + if m.game.Players[1].Balance != m.game.StartingCapital { + t.Errorf("новый бот должен получить стартовый капитал %d, получено %d", + m.game.StartingCapital, m.game.Players[1].Balance) + } + if m.game.Players[1].IsHuman { + t.Errorf("заменённое место не должно быть отмечено как человек") + } + if _, ok := m.bots[1]; !ok { + t.Errorf("для места 1 должен быть назначен новый бот в m.bots") + } +} + +func TestCheckBankruptciesReplacesHuman(t *testing.T) { + m := NewModel() + m.game.Players[m.humanIdx].Balance = -5 // ушёл в минус + + msg := m.checkBankruptcies() + if msg == "" { + t.Fatalf("ожидалось сообщение о банкротстве человека") + } + if m.humanIdx != -1 { + t.Fatalf("после банкротства человека humanIdx должен стать -1, получено %d", m.humanIdx) + } + if m.game.Players[0].IsHuman { + t.Errorf("бывшее место человека не должно остаться отмеченным как человек") + } + if _, ok := m.bots[0]; !ok { + t.Errorf("для бывшего места человека должен быть назначен бот") + } +} + +func TestCheckBankruptciesNoneWhenSolvent(t *testing.T) { + m := NewModel() + msg := m.checkBankruptcies() + if msg != "" { + t.Errorf("не ожидалось сообщений о банкротстве при положительных балансах, получено: %q", msg) + } +} + +func TestStartNewRoundReplacesAndDealsBankruptPlayer(t *testing.T) { + m := NewModel() + m.game.Players[2].Balance = 0 + // доводим раунд до конца, чтобы можно было легально стартовать новый + m.game.Players[m.humanIdx].Hand = []Card{c(Ace, Clubs)} + m.game.CurrentPlayer = m.humanIdx + m.game.Phase = PhaseAction + if err := m.game.Discard(c(Ace, Clubs)); err != nil { + t.Fatalf("не удалось завершить раунд для теста: %v", err) + } + + m.startNewRound() + + if len(m.game.Players[2].Hand) != 5 { + t.Fatalf("новый бот должен получить обычную раздачу из 5 карт, получено %d", + len(m.game.Players[2].Hand)) + } + if m.message == "" { + t.Errorf("ожидалось информационное сообщение о замене игрока") + } +} + +func TestRandomBotStrategyReturnsKnownLevel(t *testing.T) { + seenLevels := map[string]bool{} + for i := 0; i < 100; i++ { + _, level := randomBotStrategy() + seenLevels[level] = true + } + if len(seenLevels) == 0 { + t.Fatalf("randomBotStrategy не вернула ни одного уровня") + } + for level := range seenLevels { + found := false + for _, l := range botLevels { + if l.Suffix == level { + found = true + break + } + } + if !found { + t.Errorf("неизвестный уровень бота: %q", level) + } + } +} diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..1877a69 --- /dev/null +++ b/banner.go @@ -0,0 +1,16 @@ +package main + +// asciiBanner — ASCII-заголовок "GO GAMES COLLECTION", сгенерирован figlet (шрифт standard). +const asciiBanner = ` + ____ ___ ____ _ __ __ _____ ____ + / ___|/ _ \ / ___| / \ | \/ | ____/ ___| +| | _| | | | | | _ / _ \ | |\/| | _| \___ \ +| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) | + \____|\___/ \____/_/ \_\_| |_|_____|____/ + + ____ ___ _ _ _____ ____ _____ ___ ___ _ _ + / ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | | +| | | | | | | | | | _|| | | | | | | | | \| | +| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ | + \____\___/|_____|_____|_____\____| |_| |___\___/|_| \_| + ` diff --git a/blackjack_bot.go b/blackjack_bot.go new file mode 100644 index 0000000..fd32255 --- /dev/null +++ b/blackjack_bot.go @@ -0,0 +1,39 @@ +package main + +// BlackjackBot — единственная (без градации уровней) стратегия +// бота для Блэкджека: играет по упрощённой базовой стратегии — +// берёт карту, пока сумма меньше 17 (как и дилер), и удваивает +// ставку на жёсткой сумме 11 из первых двух карт, если хватает +// капитала. +type BlackjackBot struct{} + +// DecideAction решает, что делать текущему игроку: "hit", "stand" +// или "double". +func (BlackjackBot) DecideAction(g *BlackjackGameState) string { + p := g.current() + total := handTotal(p.Hand) + + if len(p.Hand) == 2 && total == 11 && p.Balance >= p.Bet { + return "double" + } + if total < 17 { + return "hit" + } + return "stand" +} + +// PlayFullTurn исполняет одно решение бота — используется при +// автоматической прокрутке партии. +func (b BlackjackBot) PlayFullTurn(g *BlackjackGameState) error { + if g.Phase != BJPhasePlayerTurn { + return nil + } + switch b.DecideAction(g) { + case "double": + return g.DoubleDown() + case "hit": + return g.Hit() + default: + return g.Stand() + } +} diff --git a/blackjack_game.go b/blackjack_game.go new file mode 100644 index 0000000..2a6f8ab --- /dev/null +++ b/blackjack_game.go @@ -0,0 +1,340 @@ +package main + +import "errors" + +// ПРАВИЛА В ЭТОЙ РЕАЛИЗАЦИИ: каждый игрок играет против дилера (не +// друг против друга). Дилер берёт карты, пока сумма меньше 17, и +// останавливается на любых 17 (включая "мягкие" 17 с тузом) — это +// осознанный выбор в пользу простоты и предсказуемости, распростра- +// нённый вариант казино-правил. Сплиты не поддерживаются, удвоение +// ставки — только на первых двух картах. + +// BlackjackStatus — состояние руки игрока в текущем раунде. +type BlackjackStatus int + +const ( + BJStatusPlaying BlackjackStatus = iota + BJStatusStood + BJStatusBusted + BJStatusBlackjack +) + +// BlackjackPlayer — состояние одного игрока за столом. +type BlackjackPlayer struct { + Name string + Hand []Card + IsHuman bool + Balance int + Bet int + Status BlackjackStatus +} + +// BJPhase — фаза текущего раунда. +type BJPhase int + +const ( + // BJPhasePlayerTurn — ходит игрок с индексом CurrentPlayerIdx: + // взять карту, остановиться или удвоить ставку. + BJPhasePlayerTurn BJPhase = iota + // BJPhaseRoundOver — раунд завершён, дилер сыграл, выплаты + // посчитаны. + BJPhaseRoundOver +) + +var ( + ErrBJWrongPhase = errors.New("недопустимое действие для текущей фазы") + ErrBJNotEnoughFunds = errors.New("недостаточно средств для этого действия") + ErrBJCannotDouble = errors.New("удвоить ставку можно только на первых двух картах") +) + +// BlackjackPlayerResult — итог одного игрока в завершившемся раунде. +type BlackjackPlayerResult struct { + Outcome string // "blackjack", "win", "push", "lose", "bust" + Delta int // изменение баланса за раунд (относительно капитала до ставки) +} + +// BlackjackResult — итог завершившегося раунда. +type BlackjackResult struct { + DealerBusted bool + DealerTotal int + PlayerResults []BlackjackPlayerResult +} + +// BlackjackGameState — полное состояние раунда блэкджека. +type BlackjackGameState struct { + Players []*BlackjackPlayer + Dealer []Card + + Deck *Deck + + CurrentPlayerIdx int + Phase BJPhase + Bet int // фиксированная базовая ставка за раунд + StartingCapital int + Result *BlackjackResult + + preRoundBalance []int +} + +// blackjackCardValue — очковая стоимость карты для блэкджека: туз +// по умолчанию считается за 11 (см. handTotal, где мягкие тузы при +// переборе понижаются до 1), картинки — за 10, остальные — по +// номиналу. +func blackjackCardValue(r Rank) int { + switch { + case r == Ace: + return 11 + case r >= Jack: + return 10 + default: + return int(r) + } +} + +// handTotal считает сумму очков руки с учётом мягких тузов: каждый +// туз сначала считается за 11, но при переборе поочерёдно +// понижается до 1, пока сумма не окажется 21 или меньше (либо тузы +// не закончатся). +func handTotal(hand []Card) int { + total := 0 + aces := 0 + for _, c := range hand { + total += blackjackCardValue(c.Rank) + if c.Rank == Ace { + aces++ + } + } + for total > 21 && aces > 0 { + total -= 10 + aces-- + } + return total +} + +// isNaturalBlackjack проверяет, что рука — блэкджек с раздачи: ровно +// 2 карты и сумма 21 (туз + карта на 10 очков). +func isNaturalBlackjack(hand []Card) bool { + return len(hand) == 2 && handTotal(hand) == 21 +} + +// NewBlackjackGame создаёт новый раунд: тасует колоду, списывает +// ставку с каждого игрока, раздаёт по 2 карты игрокам и дилеру, +// сразу отмечает натуральные блэкджеки и передаёт ход первому +// игроку, которому есть что решать. +func NewBlackjackGame(names []string, humanIdx int, startingCapital, bet int) *BlackjackGameState { + return NewBlackjackRound(names, humanIdx, startingCapital, bet, nil) +} + +// NewBlackjackRound — то же самое, что NewBlackjackGame, но +// позволяет передать капитал игроков с предыдущего раунда (balances +// == nil означает "все со стартовым капиталом" — используется при +// самом первом раунде партии). +func NewBlackjackRound(names []string, humanIdx int, startingCapital, bet int, balances []int) *BlackjackGameState { + deck := NewDeck() + deck.Shuffle() + + players := make([]*BlackjackPlayer, len(names)) + for i, name := range names { + balance := startingCapital + if balances != nil { + balance = balances[i] + } + players[i] = &BlackjackPlayer{ + Name: name, + IsHuman: i == humanIdx, + Balance: balance, + } + } + + g := &BlackjackGameState{ + Players: players, + Deck: deck, + Bet: bet, + StartingCapital: startingCapital, + } + + g.preRoundBalance = make([]int, len(players)) + for i, p := range players { + g.preRoundBalance[i] = p.Balance + p.Balance -= bet + p.Bet = bet + } + + // раздача: по 2 карты каждому игроку и дилеру, по одной за проход + for round := 0; round < 2; round++ { + for _, p := range g.Players { + p.Hand = append(p.Hand, g.draw()) + } + g.Dealer = append(g.Dealer, g.draw()) + } + + for _, p := range g.Players { + if isNaturalBlackjack(p.Hand) { + p.Status = BJStatusBlackjack + } + } + + g.CurrentPlayerIdx = -1 + g.advanceToNextPlayerOrResolve() + return g +} + +func (g *BlackjackGameState) draw() Card { + if g.Deck.Len() == 0 { + // вырожденный случай: даже одна полная колода на 6 игроков + // не должна кончиться за один раунд, но на всякий случай + // подмешиваем новую, чтобы не паниковать в рантайме + fresh := NewDeck() + fresh.Shuffle() + g.Deck = fresh + } + c, _ := g.Deck.Draw() + return c +} + +func (g *BlackjackGameState) current() *BlackjackPlayer { + if g.CurrentPlayerIdx < 0 || g.CurrentPlayerIdx >= len(g.Players) { + return nil + } + return g.Players[g.CurrentPlayerIdx] +} + +// Hit — текущий игрок берёт ещё одну карту. +func (g *BlackjackGameState) Hit() error { + if g.Phase != BJPhasePlayerTurn { + return ErrBJWrongPhase + } + p := g.current() + p.Hand = append(p.Hand, g.draw()) + if handTotal(p.Hand) > 21 { + p.Status = BJStatusBusted + g.advanceToNextPlayerOrResolve() + } + return nil +} + +// Stand — текущий игрок останавливается со своей текущей суммой. +func (g *BlackjackGameState) Stand() error { + if g.Phase != BJPhasePlayerTurn { + return ErrBJWrongPhase + } + p := g.current() + p.Status = BJStatusStood + g.advanceToNextPlayerOrResolve() + return nil +} + +// DoubleDown — удвоить ставку, взять ровно одну карту и +// автоматически остановиться. Доступно только на первых двух картах +// и только если хватает капитала на доплату ставки. +func (g *BlackjackGameState) DoubleDown() error { + if g.Phase != BJPhasePlayerTurn { + return ErrBJWrongPhase + } + p := g.current() + if len(p.Hand) != 2 { + return ErrBJCannotDouble + } + if p.Balance < p.Bet { + return ErrBJNotEnoughFunds + } + p.Balance -= p.Bet + p.Bet *= 2 + p.Hand = append(p.Hand, g.draw()) + if handTotal(p.Hand) > 21 { + p.Status = BJStatusBusted + } else { + p.Status = BJStatusStood + } + g.advanceToNextPlayerOrResolve() + return nil +} + +// advanceToNextPlayerOrResolve передаёт ход следующему игроку, у +// которого статус ещё "playing"; если таких не осталось — доигрывает +// дилера и считает выплаты. +func (g *BlackjackGameState) advanceToNextPlayerOrResolve() { + for idx := g.CurrentPlayerIdx + 1; idx < len(g.Players); idx++ { + if g.Players[idx].Status == BJStatusPlaying { + g.CurrentPlayerIdx = idx + g.Phase = BJPhasePlayerTurn + return + } + } + g.resolveDealerAndPayouts() +} + +// resolveDealerAndPayouts доигрывает руку дилера (берёт карты, пока +// сумма меньше 17, останавливается на любых 17) и рассчитывает +// выплаты каждому игроку. +func (g *BlackjackGameState) resolveDealerAndPayouts() { + anyoneNeedsDealer := false + for _, p := range g.Players { + if p.Status == BJStatusStood || p.Status == BJStatusBlackjack { + anyoneNeedsDealer = true + break + } + } + if anyoneNeedsDealer { + for handTotal(g.Dealer) < 17 { + g.Dealer = append(g.Dealer, g.draw()) + } + } + + dealerTotal := handTotal(g.Dealer) + dealerBusted := dealerTotal > 21 + dealerBlackjack := isNaturalBlackjack(g.Dealer) + + results := make([]BlackjackPlayerResult, len(g.Players)) + for i, p := range g.Players { + switch p.Status { + case BJStatusBusted: + results[i] = BlackjackPlayerResult{Outcome: "bust", Delta: 0} + case BJStatusBlackjack: + if dealerBlackjack { + p.Balance += p.Bet + results[i] = BlackjackPlayerResult{Outcome: "push"} + } else { + payout := p.Bet + p.Bet*3/2 + p.Balance += payout + results[i] = BlackjackPlayerResult{Outcome: "blackjack"} + } + case BJStatusStood: + playerTotal := handTotal(p.Hand) + switch { + case dealerBusted || playerTotal > dealerTotal: + p.Balance += p.Bet * 2 + results[i] = BlackjackPlayerResult{Outcome: "win"} + case playerTotal == dealerTotal: + p.Balance += p.Bet + results[i] = BlackjackPlayerResult{Outcome: "push"} + default: + results[i] = BlackjackPlayerResult{Outcome: "lose"} + } + } + results[i].Delta = p.Balance - g.preRoundBalance[i] + } + + g.Result = &BlackjackResult{ + DealerBusted: dealerBusted, + DealerTotal: dealerTotal, + PlayerResults: results, + } + g.Phase = BJPhaseRoundOver +} + +// NextRound начинает новый раунд, перенося капитал игроков из +// завершившегося раунда. +func (g *BlackjackGameState) NextRound() *BlackjackGameState { + names := make([]string, len(g.Players)) + balances := make([]int, len(g.Players)) + humanIdx := -1 + for i, p := range g.Players { + names[i] = p.Name + balances[i] = p.Balance + if p.IsHuman { + humanIdx = i + } + } + return NewBlackjackRound(names, humanIdx, g.StartingCapital, g.Bet, balances) +} diff --git a/blackjack_game_test.go b/blackjack_game_test.go new file mode 100644 index 0000000..f69689a --- /dev/null +++ b/blackjack_game_test.go @@ -0,0 +1,358 @@ +package main + +import "testing" + +func bc(rank Rank, suit Suit) Card { return Card{Rank: rank, Suit: suit} } + +// resetForManualBout приводит только что созданную игру в чистое +// состояние перед ручной подстановкой рук в тесте. Это нужно, +// потому что NewBlackjackGame раздаёт случайные карты — и если +// кому-то случайно выпадает натуральный блэкджек, конструктор сам +// доигрывает и разрешает раунд ещё до того, как тест успевает +// подставить нужные карты. Без сброса такой "случайный" исход +// остаётся в g.Result и в Balance, и повторный вызов +// advanceToNextPlayerOrResolve в тесте задваивает выплату. +func resetForManualBout(g *BlackjackGameState) { + g.Result = nil + g.Phase = BJPhasePlayerTurn + for i, p := range g.Players { + p.Status = BJStatusPlaying + p.Balance = g.preRoundBalance[i] - p.Bet + } +} + +func TestHandTotalBasic(t *testing.T) { + cases := []struct { + hand []Card + want int + }{ + {[]Card{bc(King, Clubs), bc(Seven, Hearts)}, 17}, + {[]Card{bc(Ace, Clubs), bc(King, Hearts)}, 21}, + {[]Card{bc(Ace, Clubs), bc(Ace, Hearts)}, 12}, // 11+1 + {[]Card{bc(Ace, Clubs), bc(Ace, Hearts), bc(Nine, Diamonds)}, 21}, // 1+1+9+... нет: 11+1+9=21 + {[]Card{bc(King, Clubs), bc(Queen, Hearts), bc(Two, Diamonds)}, 22}, + } + for i, tc := range cases { + got := handTotal(tc.hand) + if got != tc.want { + t.Errorf("случай %d: ожидалось %d, получено %d (рука %v)", i, tc.want, got, tc.hand) + } + } +} + +func TestIsNaturalBlackjack(t *testing.T) { + if !isNaturalBlackjack([]Card{bc(Ace, Clubs), bc(King, Hearts)}) { + t.Errorf("туз+король должен быть натуральным блэкджеком") + } + if isNaturalBlackjack([]Card{bc(Seven, Clubs), bc(Seven, Hearts), bc(Seven, Diamonds)}) { + t.Errorf("21 из трёх карт не должен считаться натуральным блэкджеком") + } + if isNaturalBlackjack([]Card{bc(King, Clubs), bc(Queen, Hearts)}) { + t.Errorf("20 не должен считаться блэкджеком") + } +} + +func TestNewBlackjackGameDealsAndCollectsBet(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + for i, p := range g.Players { + // ровно 2 карты игроку раздаются всегда, независимо от + // исхода — даже если у кого-то мгновенно блэкджек и раунд + // уже разрешился, руки игроков от этого не меняются + if len(p.Hand) != 2 { + t.Errorf("игрок %d: ожидалось 2 карты, получено %d", i, len(p.Hand)) + } + if p.Bet != 10 { + t.Errorf("игрок %d: ожидалась ставка 10, получено %d", i, p.Bet) + } + // капитал сразу после ставки (90) проверяем только пока + // раунд ещё не разрешился сам — если кому-то выпал + // натуральный блэкджек, конструктор уже доиграл дилера и + // начислил выплату, и Balance закономерно отличается от 90 + if p.Status == BJStatusPlaying && g.Phase == BJPhasePlayerTurn { + if p.Balance != 90 { + t.Errorf("игрок %d: ожидался капитал 90 после ставки, получено %d", i, p.Balance) + } + } + } + if len(g.Dealer) < 2 { + t.Errorf("у дилера должно быть минимум 2 карты, получено %d", len(g.Dealer)) + } +} + +func TestHitAddsCardAndBustEndsTurn(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts)} // 20 + g.Players[0].Status = BJStatusPlaying + g.CurrentPlayerIdx = 0 + g.Phase = BJPhasePlayerTurn + // подменяем колоду, чтобы гарантированно добрать карту с перебором + g.Deck = &Deck{cards: []Card{bc(Five, Diamonds)}} + + if err := g.Hit(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Players[0].Status != BJStatusBusted { + t.Fatalf("ожидался перебор, статус: %v", g.Players[0].Status) + } + if g.CurrentPlayerIdx != 1 { + t.Fatalf("ход должен был перейти к игроку 1, получено %d", g.CurrentPlayerIdx) + } +} + +func TestStandMovesToNextPlayer(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Status = BJStatusPlaying + g.Players[1].Status = BJStatusPlaying + g.CurrentPlayerIdx = 0 + g.Phase = BJPhasePlayerTurn + + if err := g.Stand(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Players[0].Status != BJStatusStood { + t.Fatalf("ожидался статус stood") + } + if g.CurrentPlayerIdx != 1 { + t.Fatalf("ход должен был перейти к игроку 1") + } +} + +func TestDoubleDownDoublesbetAndDrawsOneCard(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)} // 11 + g.Players[0].Status = BJStatusPlaying + g.Players[0].Balance = 90 + g.Players[0].Bet = 10 + g.CurrentPlayerIdx = 0 + g.Phase = BJPhasePlayerTurn + g.Deck = &Deck{cards: []Card{bc(King, Diamonds)}} + + if err := g.DoubleDown(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Players[0].Bet != 20 { + t.Errorf("ставка должна была удвоиться, получено %d", g.Players[0].Bet) + } + if g.Players[0].Balance != 80 { + t.Errorf("ожидался капитал 80 после доплаты ставки, получено %d", g.Players[0].Balance) + } + if len(g.Players[0].Hand) != 3 { + t.Errorf("после удвоения должна была добавиться ровно 1 карта, получено %d карт", len(g.Players[0].Hand)) + } + if g.Players[0].Status != BJStatusStood { + t.Errorf("после удвоения (11+король=21, не перебор) статус должен быть stood, получено %v", g.Players[0].Status) + } +} + +func TestDoubleDownRejectsWithMoreThanTwoCards(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts), bc(Two, Diamonds)} + g.Players[0].Status = BJStatusPlaying + g.CurrentPlayerIdx = 0 + g.Phase = BJPhasePlayerTurn + + if err := g.DoubleDown(); err != ErrBJCannotDouble { + t.Errorf("ожидалась ошибка ErrBJCannotDouble, получено: %v", err) + } +} + +func TestDoubleDownRejectsInsufficientFunds(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)} + g.Players[0].Status = BJStatusPlaying + g.Players[0].Balance = 5 // меньше ставки + g.Players[0].Bet = 10 + g.CurrentPlayerIdx = 0 + g.Phase = BJPhasePlayerTurn + + if err := g.DoubleDown(); err != ErrBJNotEnoughFunds { + t.Errorf("ожидалась ошибка ErrBJNotEnoughFunds, получено: %v", err) + } +} + +func TestPlayerBlackjackBeatsDealerNonBlackjack(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)} + g.Players[0].Status = BJStatusBlackjack + g.Dealer = []Card{bc(Nine, Clubs), bc(Seven, Hearts)} // 16, дилер должен добрать + g.Deck = &Deck{cards: []Card{bc(Two, Diamonds)}} // 16+2=18, останов + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + if g.Phase != BJPhaseRoundOver { + t.Fatalf("раунд должен был завершиться") + } + res := g.Result.PlayerResults[0] + if res.Outcome != "blackjack" { + t.Fatalf("ожидался исход blackjack, получено %s", res.Outcome) + } + // ставка 10: блэкджек платит 3:2 -> получает 10+15=25, было 90 (после + // списания ставки) -> дельта относительно ДО раунда (100): +15 + if res.Delta != 15 { + t.Errorf("ожидалась дельта +15, получено %d", res.Delta) + } +} + +func TestPushWhenBothBlackjack(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)} + g.Players[0].Status = BJStatusBlackjack + g.Dealer = []Card{bc(Ace, Diamonds), bc(Queen, Spades)} + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + res := g.Result.PlayerResults[0] + if res.Outcome != "push" { + t.Fatalf("ожидался push при блэкджеке у обоих, получено %s", res.Outcome) + } + if res.Delta != 0 { + t.Errorf("ожидалась нулевая дельта при push, получено %d", res.Delta) + } +} + +func TestDealerBustPaysStandingPlayer(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 + g.Players[0].Status = BJStatusStood + g.Dealer = []Card{bc(King, Diamonds), bc(Six, Spades)} // 16, обязан добрать + g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // 16+10=26, перебор + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + if !g.Result.DealerBusted { + t.Fatalf("дилер должен был перебрать") + } + res := g.Result.PlayerResults[0] + if res.Outcome != "win" { + t.Fatalf("ожидался win при переборе дилера, получено %s", res.Outcome) + } + if res.Delta != 10 { + t.Errorf("ожидалась дельта +10 (выигрыш 1:1), получено %d", res.Delta) + } +} + +func TestBustedPlayerLosesRegardlessOfDealer(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts), bc(Five, Diamonds)} // 25 + g.Players[0].Status = BJStatusBusted + g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19 + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + res := g.Result.PlayerResults[0] + if res.Outcome != "bust" { + t.Fatalf("ожидался исход bust, получено %s", res.Outcome) + } + if res.Delta != -10 { + t.Errorf("ожидалась дельта -10 (потеря ставки), получено %d", res.Delta) + } +} + +func TestPushOnEqualTotals(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 + g.Players[0].Status = BJStatusStood + g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19, останов без добора + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + res := g.Result.PlayerResults[0] + if res.Outcome != "push" { + t.Fatalf("ожидался push при равных суммах, получено %s", res.Outcome) + } + if res.Delta != 0 { + t.Errorf("ожидалась нулевая дельта, получено %d", res.Delta) + } +} + +func TestDealerStandsOnAllSeventeens(t *testing.T) { + g := NewBlackjackGame([]string{"A"}, -1, 100, 10) + resetForManualBout(g) + g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19 + g.Players[0].Status = BJStatusStood + // мягкие 17: туз + 6 = 17. Дилер должен остановиться, не добирая. + g.Dealer = []Card{bc(Ace, Diamonds), bc(Six, Spades)} + g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // если бы дилер тянул — перебор + g.CurrentPlayerIdx = len(g.Players) + g.advanceToNextPlayerOrResolve() + + if len(g.Dealer) != 2 { + t.Fatalf("дилер не должен был тянуть карту на мягких 17, рука: %v", g.Dealer) + } + if g.Result.DealerTotal != 17 { + t.Errorf("ожидалась сумма дилера 17, получено %d", g.Result.DealerTotal) + } +} + +func TestNextRoundCarriesOverBalance(t *testing.T) { + g := NewBlackjackGame([]string{"A", "B"}, 0, 100, 10) + g.Players[0].Balance = 150 + g.Players[1].Balance = 42 + + next := g.NextRound() + // Капитал должен перенестись, а новая ставка — списаться заново. + // Проверяем именно preRoundBalance (капитал ДО ставки нового + // раунда), а не текущий Balance: если кому-то в новом раунде + // случайно выпадет блэкджек, конструктор сам мгновенно разрешит + // раунд и Balance уже отразит и ставку, и выплату — это ожидаемое + // поведение движка, но не то, что здесь проверяется. + if next.preRoundBalance[0] != 150 { + t.Errorf("ожидался перенесённый капитал 150 до ставки, получено %d", next.preRoundBalance[0]) + } + if next.preRoundBalance[1] != 42 { + t.Errorf("ожидался перенесённый капитал 42 до ставки, получено %d", next.preRoundBalance[1]) + } + if next.Players[0].Balance != 140 && next.Phase == BJPhasePlayerTurn { + t.Errorf("до какого-либо разрешения раунда ожидался капитал 140 (150-10), получено %d", next.Players[0].Balance) + } + if !next.Players[0].IsHuman { + t.Errorf("флаг IsHuman должен был сохраниться при переносе") + } +} + +// TestBlackjackFullSimulation прогоняет много полных раундов с +// ботами разного числа игроков, проверяя отсутствие паник и +// корректность (капитал не должен непредсказуемо телепортироваться: +// сумма всех дельт равна сумме (dealer-neutral, т.е. каждый платится +// независимо от банка, поэтому проверяем только отсутствие паник и +// что результат всегда присутствует). +func TestBlackjackFullSimulation(t *testing.T) { + bot := BlackjackBot{} + for numPlayers := 1; numPlayers <= 6; numPlayers++ { + for round := 0; round < 50; round++ { + names := make([]string, numPlayers) + for i := range names { + names[i] = "Bot" + } + g := NewBlackjackGame(names, -1, 1000, 10) + + const maxSteps = 100 + steps := 0 + for ; steps < maxSteps && g.Phase != BJPhaseRoundOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("игроков %d, раунд %d: ошибка бота: %v", numPlayers, round, err) + } + } + if steps >= maxSteps { + t.Fatalf("игроков %d, раунд %d: раунд не завершился за %d шагов", numPlayers, round, maxSteps) + } + if g.Result == nil { + t.Fatalf("игроков %d, раунд %d: раунд завершился без результата", numPlayers, round) + } + if len(g.Result.PlayerResults) != numPlayers { + t.Fatalf("игроков %d, раунд %d: ожидалось %d результатов, получено %d", + numPlayers, round, numPlayers, len(g.Result.PlayerResults)) + } + } + } +} diff --git a/blackjack_tui.go b/blackjack_tui.go new file mode 100644 index 0000000..4c2dcb3 --- /dev/null +++ b/blackjack_tui.go @@ -0,0 +1,276 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// BlackjackModel — TUI поверх BlackjackGameState. Действий у +// игрока немного и они не требуют выбора карты (в отличие от +// Тонка/Дурака) — только hit/stand/double, поэтому курсор по руке +// не нужен. +type BlackjackModel struct { + game *BlackjackGameState + humanIdx int + bot BlackjackBot + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewBlackjackModel создаёт новую партию: человек — игрок 0, дальше +// numPlayers-1 ботов со случайными именами (тот же пул, что и в +// Тонке — это просто имена, без всякого смысла про уровень игры). +func NewBlackjackModel(numPlayers, startingCapital, bet int) BlackjackModel { + if numPlayers < 1 { + numPlayers = 1 + } + if numPlayers > 6 { + numPlayers = 6 + } + names := []string{"Вы"} + if numPlayers > 1 { + botNames, _ := newBotRoster(numPlayers - 1) + names = append(names, botNames...) + } + + return BlackjackModel{ + game: NewBlackjackGame(names, 0, startingCapital, bet), + humanIdx: 0, + bot: BlackjackBot{}, + } +} + +func (m BlackjackModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +func (m BlackjackModel) maybeScheduleBot() tea.Cmd { + if m.game.Phase != BJPhasePlayerTurn { + return nil + } + if m.game.CurrentPlayerIdx == m.humanIdx { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *BlackjackModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *BlackjackModel) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m BlackjackModel) 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 BlackjackModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Phase != BJPhasePlayerTurn { + return m, nil + } + idx := m.game.CurrentPlayerIdx + if idx == m.humanIdx { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game); err != nil { + m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + return m, nil + } + m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + return m, m.maybeScheduleBot() +} + +func (m BlackjackModel) 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 == BJPhaseRoundOver { + if key == "n" { + m.game = m.game.NextRound() + m.message = "" + m.isError = false + return m, m.maybeScheduleBot() + } + return m, nil + } + + if m.game.CurrentPlayerIdx != m.humanIdx { + // сейчас ходит бот — игнорируем остальной ввод + return m, nil + } + + switch key { + case "h": + if err := m.game.Hit(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы взяли карту.") + return m, m.maybeScheduleBot() + case "s": + if err := m.game.Stand(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы остановились.") + return m, m.maybeScheduleBot() + case "d": + if err := m.game.DoubleDown(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы удвоили ставку.") + return m, m.maybeScheduleBot() + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +func renderHandCards(hand []Card) string { + if len(hand) == 0 { + return dimStyle.Render("(нет карт)") + } + parts := make([]string, len(hand)) + for i, c := range hand { + parts[i] = renderCard(c) + } + return strings.Join(parts, " ") +} + +func statusLabel(s BlackjackStatus) string { + switch s { + case BJStatusStood: + return "стоп" + case BJStatusBusted: + return "перебор" + case BJStatusBlackjack: + return "блэкджек!" + default: + return "ходит" + } +} + +func (m BlackjackModel) renderPlayers() string { + var b strings.Builder + for i, p := range m.game.Players { + marker := " " + if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx { + marker = "> " + } + name := p.Name + if i == m.humanIdx { + name += " (вы)" + } + total := handTotal(p.Hand) + line := fmt.Sprintf("%s%-20s ставка: %-4d карты: %s (%d) [%s]", + marker, name, p.Bet, renderHandCards(p.Hand), total, statusLabel(p.Status)) + if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx { + line = turnStyle.Render(line) + } + fmt.Fprintln(&b, line) + } + return strings.TrimRight(b.String(), "\n") +} + +func (m BlackjackModel) renderDealer() string { + if m.game.Phase == BJPhasePlayerTurn { + // вторая карта дилера скрыта, пока не завершится раунд + hidden := dimStyle.Render("??") + return fmt.Sprintf("%s %s", renderCard(m.game.Dealer[0]), hidden) + } + return fmt.Sprintf("%s (%d)", renderHandCards(m.game.Dealer), handTotal(m.game.Dealer)) +} + +func (m BlackjackModel) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== БЛЭКДЖЕК ===")) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Дилер: %s\n\n", m.renderDealer()) + fmt.Fprintln(&b, m.renderPlayers()) + fmt.Fprintln(&b) + + if m.game.Phase == BJPhaseRoundOver { + fmt.Fprintln(&b, m.renderResult()) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + return b.String() + } + + if m.game.CurrentPlayerIdx == m.humanIdx { + fmt.Fprintln(&b, "[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню") + } else { + fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayerIdx].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 BlackjackModel) renderResult() string { + res := m.game.Result + var b strings.Builder + dealerLine := fmt.Sprintf("Дилер: %s (%d)", renderHandCards(m.game.Dealer), res.DealerTotal) + if res.DealerBusted { + dealerLine += " — перебор!" + } + fmt.Fprintln(&b, dealerLine) + fmt.Fprintln(&b) + + outcomeText := map[string]string{ + "blackjack": "блэкджек! выигрыш 3:2", + "win": "выигрыш", + "push": "ничья, ставка возвращена", + "lose": "проигрыш", + "bust": "перебор, проигрыш", + } + for i, p := range m.game.Players { + r := res.PlayerResults[i] + style := infoStyle + if r.Delta < 0 { + style = errorStyle + } else if r.Delta > 0 { + style = winStyle + } + fmt.Fprintln(&b, style.Render(fmt.Sprintf(" %-20s %-24s изменение капитала: %+d (итого: %d)", + p.Name, outcomeText[r.Outcome], r.Delta, p.Balance))) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/blackjack_tui_test.go b/blackjack_tui_test.go new file mode 100644 index 0000000..ced9f99 --- /dev/null +++ b/blackjack_tui_test.go @@ -0,0 +1,155 @@ +package main + +import "testing" + +func TestBlackjackModelDealsCorrectPlayerCount(t *testing.T) { + for n := 1; n <= 6; n++ { + m := NewBlackjackModel(n, 100, 10) + if len(m.game.Players) != n { + t.Errorf("ожидалось %d игроков, получено %d", n, len(m.game.Players)) + } + } +} + +func TestBlackjackModelClampsPlayerCount(t *testing.T) { + m := NewBlackjackModel(0, 100, 10) + if len(m.game.Players) != 1 { + t.Errorf("ожидалось ограничение снизу до 1 игрока, получено %d", len(m.game.Players)) + } + m = NewBlackjackModel(10, 100, 10) + if len(m.game.Players) != 6 { + t.Errorf("ожидалось ограничение сверху до 6 игроков, получено %d", len(m.game.Players)) + } +} + +func TestBlackjackModelHitViaKeys(t *testing.T) { + m := NewBlackjackModel(1, 100, 10) + resetForManualBout(m.game) + m.game.Players[0].Hand = []Card{c(King, Clubs), c(Queen, Hearts)} + m.game.CurrentPlayerIdx = 0 + m.game.Phase = BJPhasePlayerTurn + m.game.Deck = &Deck{cards: []Card{c(Five, Diamonds)}} + + next, _ := m.Update(key("h")) + m = next.(BlackjackModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.game.Players[0].Status != BJStatusBusted { + t.Fatalf("ожидался перебор, статус: %v", m.game.Players[0].Status) + } +} + +func TestBlackjackModelStandViaKeys(t *testing.T) { + m := NewBlackjackModel(1, 100, 10) + resetForManualBout(m.game) + m.game.CurrentPlayerIdx = 0 + m.game.Phase = BJPhasePlayerTurn + + next, _ := m.Update(key("s")) + m = next.(BlackjackModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.game.Phase != BJPhaseRoundOver { + t.Fatalf("с одним игроком после stand раунд должен был сразу завершиться") + } +} + +func TestBlackjackModelDoubleViaKeys(t *testing.T) { + m := NewBlackjackModel(1, 100, 10) + resetForManualBout(m.game) + m.game.Players[0].Hand = []Card{c(Five, Clubs), c(Six, Hearts)} + m.game.CurrentPlayerIdx = 0 + m.game.Phase = BJPhasePlayerTurn + m.game.Deck = &Deck{cards: []Card{c(King, Diamonds)}} + + next, _ := m.Update(key("d")) + m = next.(BlackjackModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.game.Players[0].Bet != 20 { + t.Fatalf("ставка должна была удвоиться, получено %d", m.game.Players[0].Bet) + } +} + +func TestBlackjackModelQReturnsToMenu(t *testing.T) { + m := NewBlackjackModel(2, 100, 10) + next, _ := m.Update(key("q")) + m = next.(BlackjackModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestBlackjackModelNewRoundCarriesBalance(t *testing.T) { + m := NewBlackjackModel(1, 100, 10) + resetForManualBout(m.game) + m.game.CurrentPlayerIdx = 0 + m.game.Phase = BJPhasePlayerTurn + + next, _ := m.Update(key("s")) + m = next.(BlackjackModel) + if m.game.Phase != BJPhaseRoundOver { + t.Fatalf("раунд должен был завершиться") + } + balanceAfterRound := m.game.Players[0].Balance + + next, _ = m.Update(key("n")) + m = next.(BlackjackModel) + // с одним игроком новый раунд может мгновенно разрешиться сам, + // если случайно выпадет блэкджек с раздачи — это ожидаемое + // поведение, а не баг, поэтому проверяем именно перенесённый + // капитал (preRoundBalance), а не итоговый Balance + if m.game.preRoundBalance[0] != balanceAfterRound { + t.Fatalf("капитал должен был перенестись в новый раунд: ожидалось %d, получено %d", + balanceAfterRound, m.game.preRoundBalance[0]) + } +} + +// TestBlackjackModelFullAutoPlaythrough прогоняет TUI-модель от +// начала до конца для человека, отыгрывающего по той же логике, что +// и бот, но через реальные нажатия клавиш — для нескольких +// вариантов числа игроков и нескольких раундов подряд. +func TestBlackjackModelFullAutoPlaythrough(t *testing.T) { + bot := BlackjackBot{} + for n := 1; n <= 6; n++ { + m := NewBlackjackModel(n, 1000, 10) + for round := 0; round < 20; round++ { + const maxSteps = 200 + steps := 0 + for m.game.Phase != BJPhaseRoundOver && steps < maxSteps { + steps++ + if m.game.CurrentPlayerIdx != m.humanIdx { + next, _ := m.Update(botMoveMsg{}) + m = next.(BlackjackModel) + continue + } + action := bot.DecideAction(m.game) + var k string + switch action { + case "double": + k = "d" + case "hit": + k = "h" + default: + k = "s" + } + next, _ := m.Update(key(k)) + m = next.(BlackjackModel) + } + if steps >= maxSteps { + t.Fatalf("игроков %d, раунд %d: раунд через TUI не завершился за %d шагов", n, round, maxSteps) + } + if m.game.Result == nil { + t.Fatalf("игроков %d, раунд %d: раунд завершился без результата", n, round) + } + next, _ := m.Update(key("n")) + m = next.(BlackjackModel) + } + } +} diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..fff52ff --- /dev/null +++ b/bot.go @@ -0,0 +1,274 @@ +package main + +import "math/rand" + +// BotStrategy — стратегия одного полного хода бота: взять карту, +// при необходимости выложить/подложить комбинации, сбросить карту +// (либо объявить Тонк, если это входит в стратегию). +type BotStrategy interface { + PlayTurn(g *GameState) error +} + +// botLevel — запись в реестре доступных уровней сложности ботов: +// используется и для явного назначения бота на место, и для +// случайного выбора (например, при замене выбывшего игрока). +type botLevel struct { + Suffix string // короткое имя уровня для отображения в UI, напр. "средний" + New func() BotStrategy +} + +var botLevels = []botLevel{ + {Suffix: "простой", New: func() BotStrategy { return SimpleBot{} }}, + {Suffix: "средний", New: func() BotStrategy { return MediumBot{} }}, + {Suffix: "Тонк", New: func() BotStrategy { return TonkBot{} }}, +} + +// randomBotStrategy возвращает бота случайного уровня сложности из +// botLevels вместе с человекочитаемым названием уровня. Уровень +// используется только во внутренней логике/тестах — в интерфейсе +// игрока он не показывается (уровень бота держится в секрете). +func randomBotStrategy() (BotStrategy, string) { + lvl := botLevels[rand.Intn(len(botLevels))] + return lvl.New(), lvl.Suffix +} + +// botNamePool — имена, которые получают боты за столом. Уровень +// сложности в имени никак не отражается — игрок не должен заранее +// знать, с кем имеет дело. +var botNamePool = []string{"Костоправ", "Гоблин", "Одноглазый", "Ворон", "Ильмо"} + +// pickBotName выбирает случайное имя из botNamePool, по возможности +// не повторяющее уже занятые имена (переданные в used). Если все +// имена из пула уже заняты, допускает повтор — это лучше, чем +// оставить место вовсе без имени. +func pickBotName(used map[string]bool) string { + free := make([]string, 0, len(botNamePool)) + for _, name := range botNamePool { + if !used[name] { + free = append(free, name) + } + } + if len(free) == 0 { + free = botNamePool + } + return free[rand.Intn(len(free))] +} + +// newBotRoster формирует n мест за столом: у каждого — случайное +// (без повторов, если хватает имён в пуле) имя из botNamePool и +// независимо выбранный случайный уровень сложности. Порядок уровней +// не фиксирован (простой/средний/сложный может стоять в любом +// месте, включая повторы) — это осознанное решение, чтобы нельзя +// было угадать силу бота по его месту за столом. +func newBotRoster(n int) (names []string, strategies []BotStrategy) { + used := make(map[string]bool, n) + names = make([]string, n) + strategies = make([]BotStrategy, n) + for i := 0; i < n; i++ { + name := pickBotName(used) + used[name] = true + names[i] = name + + strat, _ := randomBotStrategy() + strategies[i] = strat + } + return names, strategies +} + +// --- Уровень 1: простой (жадный) бот ----------------------------- +// +// Берёт из сброса, только если карта сразу закрывает готовую +// комбинацию, иначе берёт из колоды. Выкладывает всё, что может. +// Сбрасывает самую тяжёлую карту, не участвующую ни в одной +// потенциальной комбинации. +type SimpleBot struct{} + +func (SimpleBot) PlayTurn(g *GameState) error { + if err := simpleDrawDecision(g); err != nil { + return err + } + if g.Phase == PhaseGameOver { + return nil + } + // по правилам Тонк может объявить любой игрок, не только + // специализированный TonkBot — иначе бот способен бесконечно + // держать на руках одну ненужную карту + if CanDeclareTonk(g.current().Hand, g.TonkThreshold) { + _, err := g.DeclareTonk() + return err + } + + layAllAvailableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + tryExtendTableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + + discard := chooseDiscardSimple(g.current().Hand) + return g.Discard(discard) +} + +func simpleDrawDecision(g *GameState) error { + if top, ok := g.PeekDiscard(); ok && wouldFormMeld(g.current().Hand, top) { + _, err := g.DrawFromDiscard() + return err + } + _, err := g.DrawFromDeck() + return err +} + +// chooseDiscardSimple выбирает карту с нулевым потенциалом +// (не участвующую ни в одной намечающейся комбинации) и +// максимальной очковой стоимостью среди таких. Если все карты +// хоть немного полезны — сбрасывает просто самую дорогую. +func chooseDiscardSimple(hand []Card) Card { + var deadWeight []Card + for _, c := range hand { + if cardPotential(hand, c) == 0 { + deadWeight = append(deadWeight, c) + } + } + pool := deadWeight + if len(pool) == 0 { + pool = hand + } + best := pool[0] + for _, c := range pool[1:] { + if c.Value() > best.Value() { + best = c + } + } + return best +} + +// --- Уровень 2: средний бот --------------------------------------- +// +// Оценивает руку целиком через cardPotential: держит карты, +// близкие к комбинации, берёт из сброса не только при немедленном +// закрытии сета/сиквенса, но и при заметном приросте потенциала. +// Сбрасывает "мёртвый груз" — карту с наименьшим потенциалом. +type MediumBot struct{} + +func (MediumBot) PlayTurn(g *GameState) error { + if err := mediumDrawDecision(g); err != nil { + return err + } + if g.Phase == PhaseGameOver { + return nil + } + if CanDeclareTonk(g.current().Hand, g.TonkThreshold) { + _, err := g.DeclareTonk() + return err + } + + layAllAvailableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + tryExtendTableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + + discard := deadestCard(g.current().Hand) + return g.Discard(discard) +} + +func mediumDrawDecision(g *GameState) error { + top, ok := g.PeekDiscard() + if ok { + if wouldFormMeld(g.current().Hand, top) { + _, err := g.DrawFromDiscard() + return err + } + // Берём из сброса и без немедленного меlda, если карта + // заметно усиливает руку (потенциал 2+ означает, что она + // почти достраивает сет или сиквенс). + trial := append(append([]Card{}, g.current().Hand...), top) + if cardPotential(trial, top) >= 2 { + _, err := g.DrawFromDiscard() + return err + } + } + _, err := g.DrawFromDeck() + return err +} + +// --- Уровень 3: бот со стратегией Тонка --------------------------- +// +// Действует как средний бот, но после каждого взятия карты +// проверяет, можно ли объявить Тонк, и делает это немедленно, не +// дожидаясь выкладывания комбинаций. Добавляет небольшую долю +// случайности в выбор сбрасываемой карты, чтобы не быть полностью +// предсказуемым. +type TonkBot struct { + Threshold int // порог объявления Тонка; 0 — использовать g.TonkThreshold +} + +func (b TonkBot) PlayTurn(g *GameState) error { + if err := mediumDrawDecision(g); err != nil { + return err + } + if g.Phase == PhaseGameOver { + return nil + } + + threshold := b.Threshold + if threshold == 0 { + threshold = g.TonkThreshold + } + if CanDeclareTonk(g.current().Hand, threshold) { + _, err := g.DeclareTonk() + return err + } + + layAllAvailableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + tryExtendTableMelds(g) + if g.Phase == PhaseGameOver { + return nil + } + + discard := chooseDiscardWithNoise(g.current().Hand) + return g.Discard(discard) +} + +// chooseDiscardWithNoise в большинстве случаев сбрасывает самую +// "мёртвую" карту, но с вероятностью ~15% сбрасывает вторую по +// бесполезности — чтобы поведение бота было чуть менее +// предсказуемым для человека, играющего против него не первый раз. +func chooseDiscardWithNoise(hand []Card) Card { + if len(hand) == 1 { + return hand[0] + } + + type scored struct { + card Card + potential int + } + ranked := make([]scored, len(hand)) + for i, c := range hand { + ranked[i] = scored{card: c, potential: cardPotential(hand, c)} + } + // сортировка пузырьком — рука короткая (обычно до 10-12 карт), + // оптимизация не нужна + for i := 0; i < len(ranked); i++ { + for j := i + 1; j < len(ranked); j++ { + worse := ranked[j].potential < ranked[i].potential || + (ranked[j].potential == ranked[i].potential && ranked[j].card.Value() > ranked[i].card.Value()) + if worse { + ranked[i], ranked[j] = ranked[j], ranked[i] + } + } + } + + if len(ranked) >= 2 && rand.Intn(100) < 15 { + return ranked[1].card + } + return ranked[0].card +} diff --git a/bot_heuristics.go b/bot_heuristics.go new file mode 100644 index 0000000..7306370 --- /dev/null +++ b/bot_heuristics.go @@ -0,0 +1,133 @@ +package main + +// containsCard проверяет наличие конкретной карты в срезе. +func containsCard(cards []Card, target Card) bool { + for _, c := range cards { + if c == target { + return true + } + } + return false +} + +// wouldFormMeld проверяет, образует ли candidate валидную +// комбинацию (сет или сиквенс) вместе с картами руки. +func wouldFormMeld(hand []Card, candidate Card) bool { + trial := append(append([]Card{}, hand...), candidate) + for _, s := range FindAllSets(trial) { + if containsCard(s, candidate) { + return true + } + } + for _, s := range FindAllSequences(trial) { + if containsCard(s, candidate) { + return true + } + } + return false +} + +// cardPotential — грубая эвристика полезности карты для будущих +// комбинаций: сколько других карт руки поддерживают её как часть +// сета (тот же ранг) или сиквенса (та же масть, ранг отличается не +// больше чем на 2 — то есть в пределах будущей связки). +func cardPotential(hand []Card, card Card) int { + support := 0 + for _, other := range hand { + if other == card { + continue + } + if other.Rank == card.Rank { + support++ + } + if other.Suit == card.Suit { + diff := int(other.Rank) - int(card.Rank) + if diff < 0 { + diff = -diff + } + if diff <= 2 { + support++ + } + } + } + return support +} + +// deadestCard находит карту руки с наименьшим потенциалом +// (см. cardPotential); при равенстве потенциала предпочитает карту +// с наибольшей очковой стоимостью — чтобы избавляться от тяжёлого +// "мёртвого груза" в первую очередь. +func deadestCard(hand []Card) Card { + best := hand[0] + bestPotential := cardPotential(hand, best) + for _, c := range hand[1:] { + p := cardPotential(hand, c) + if p < bestPotential || (p == bestPotential && c.Value() > best.Value()) { + best = c + bestPotential = p + } + } + return best +} + +// layAllAvailableMelds выкладывает на стол все комбинации, которые +// может собрать текущий игрок из своей руки, повторяя поиск после +// каждой выкладки (так как рука меняется). Возвращает количество +// выложенных комбинаций. +func layAllAvailableMelds(g *GameState) int { + laid := 0 + for { + hand := g.current().Hand + sets := FindAllSets(hand) + seqs := FindAllSequences(hand) + + var best []Card + for _, s := range sets { + if len(s) > len(best) { + best = s + } + } + for _, s := range seqs { + if len(s) > len(best) { + best = s + } + } + if best == nil { + return laid + } + if err := g.LayNewMeld(best); err != nil { + return laid + } + laid++ + if g.Phase == PhaseGameOver { + return laid + } + } +} + +// tryExtendTableMelds пытается подложить карты руки к уже +// существующим на столе комбинациям (в том числе чужим). Полезно +// боту любого уровня, чтобы не копить карты, которые уже подходят +// под стол. +func tryExtendTableMelds(g *GameState) { + for { + extended := false + for i := range g.Table { + for _, card := range g.current().Hand { + if g.AddToMeld(i, card) == nil { + extended = true + break + } + } + if g.Phase == PhaseGameOver { + return + } + if extended { + break + } + } + if !extended { + return + } + } +} diff --git a/bot_naming_test.go b/bot_naming_test.go new file mode 100644 index 0000000..0bb805b --- /dev/null +++ b/bot_naming_test.go @@ -0,0 +1,87 @@ +package main + +import "testing" + +func TestPickBotNameAvoidsUsedWhenPossible(t *testing.T) { + used := map[string]bool{} + for _, n := range botNamePool[:len(botNamePool)-1] { // все, кроме одного + used[n] = true + } + name := pickBotName(used) + if used[name] { + t.Errorf("ожидалось единственное свободное имя из пула, получено уже занятое: %q", name) + } +} + +func TestPickBotNameFallsBackWhenPoolExhausted(t *testing.T) { + used := map[string]bool{} + for _, n := range botNamePool { + used[n] = true + } + name := pickBotName(used) // пул исчерпан — допустим повтор + found := false + for _, n := range botNamePool { + if n == name { + found = true + break + } + } + if !found { + t.Errorf("даже при исчерпанном пуле имя должно быть из botNamePool, получено %q", name) + } +} + +func TestNewBotRosterDistinctNames(t *testing.T) { + names, strategies := newBotRoster(3) + if len(names) != 3 || len(strategies) != 3 { + t.Fatalf("ожидалось 3 имени и 3 стратегии, получено %d и %d", len(names), len(strategies)) + } + seen := map[string]bool{} + for _, n := range names { + if seen[n] { + t.Errorf("имена ботов не должны повторяться при наличии свободных: %v", names) + } + seen[n] = true + } +} + +func TestNewModelAssignsNamesFromPool(t *testing.T) { + m := NewModel() + pool := map[string]bool{} + for _, n := range botNamePool { + pool[n] = true + } + for i, p := range m.game.Players { + if i == m.humanIdx { + continue + } + if !pool[p.Name] { + t.Errorf("имя бота на месте %d (%q) должно быть из пула botNamePool", i, p.Name) + } + } +} + +func TestBankruptcyMessageDoesNotRevealLevel(t *testing.T) { + m := NewModel() + m.game.Players[1].Balance = 0 + msg := m.checkBankruptcies() + if msg == "" { + t.Fatalf("ожидалось сообщение о банкротстве") + } + for _, lvl := range botLevels { + if strings_Contains(msg, lvl.Suffix) { + t.Errorf("сообщение о банкротстве не должно упоминать уровень сложности (%q найдено в %q)", lvl.Suffix, msg) + } + } +} + +// strings_Contains — маленький локальный хелпер, чтобы не тянуть +// лишний импорт "strings" только ради одной проверки в тесте. +func strings_Contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/card.go b/card.go new file mode 100644 index 0000000..c9f7688 --- /dev/null +++ b/card.go @@ -0,0 +1,90 @@ +package main + +import "fmt" + +// Suit — масть карты. +type Suit int + +const ( + Clubs Suit = iota // трефы + Diamonds // бубны + Hearts // червы + Spades // пики +) + +func (s Suit) String() string { + switch s { + case Clubs: + return "♣" + case Diamonds: + return "♦" + case Hearts: + return "♥" + case Spades: + return "♠" + default: + return "?" + } +} + +// Rank — ранг карты. Туз = 1, валет/дама/король = 11/12/13. +type Rank int + +const ( + Ace Rank = iota + 1 + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Jack + Queen + King +) + +func (r Rank) String() string { + switch r { + case Ace: + return "A" + case Jack: + return "J" + case Queen: + return "Q" + case King: + return "K" + default: + return fmt.Sprintf("%d", int(r)) + } +} + +// Value возвращает очковую стоимость карты для подсчёта веса руки +// и штрафов: туз = 1, картинки = 10, остальные — номинал. +func (r Rank) Value() int { + switch { + case r == Ace: + return 1 + case r >= Jack: // J, Q, K + return 10 + default: + return int(r) + } +} + +// Card — игральная карта. +type Card struct { + Suit Suit + Rank Rank +} + +func (c Card) String() string { + return fmt.Sprintf("%s%s", c.Rank, c.Suit) +} + +// Value — очковая стоимость карты (см. Rank.Value). +func (c Card) Value() int { + return c.Rank.Value() +} diff --git a/checkers_bot.go b/checkers_bot.go new file mode 100644 index 0000000..b289c9b --- /dev/null +++ b/checkers_bot.go @@ -0,0 +1,217 @@ +package main + +import "math" + +// CheckersBot — бот на основе минимакса с альфа-бета отсечением. +// Оценка позиции простая: количество шашек (дамка весит дороже +// простой) плюс небольшой бонус за продвижение к последней +// горизонтали — этого достаточно для разумной, но не идеальной игры. +type CheckersBot struct { + Depth int // глубина поиска в полуходах; 0 -> используется значение по умолчанию +} + +// Уровни сложности: разница только в глубине поиска минимакса — +// более глубокий просчёт означает более сильную (но и более +// медленную) игру. На доступном железе даже "сильный" уровень +// отвечает за доли секунды. +const ( + CheckersDifficultyEasy = 3 // "Простой" — быстрый, ощутимо слабее + CheckersDifficultyHard = 8 // "Сильный" — заметно сильнее, чуть медленнее +) + +const defaultCheckersDepth = 5 + +// checkersMoveStep — один шаг хода (from->to), достаточный для +// вызова g.Move один раз; серии взятий бот проходит по одному шагу, +// как и человек через TUI. +type checkersMoveStep struct { + From CheckersPos + To CheckersPos +} + +// legalStepsForColor перечисляет все допустимые ходы (from->to) для +// стороны color при текущем состоянии g (учитывая обязательное +// взятие и середину серии, если применимо). +func legalStepsForColor(g *CheckersGameState, color CheckersColor) []checkersMoveStep { + var out []checkersMoveStep + if g.ChainPos != nil { + for _, opt := range g.LegalMovesFrom(*g.ChainPos) { + out = append(out, checkersMoveStep{From: *g.ChainPos, To: opt.To}) + } + return out + } + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil || p.Color != color { + continue + } + pos := CheckersPos{Row: r, Col: c} + for _, opt := range g.LegalMovesFrom(pos) { + out = append(out, checkersMoveStep{From: pos, To: opt.To}) + } + } + } + return out +} + +// cloneCheckers создаёт независимую глубокую копию состояния — нужна +// минимаксу для просчёта ходов без изменения реальной партии. +func cloneCheckers(g *CheckersGameState) *CheckersGameState { + clone := &CheckersGameState{ + Turn: g.Turn, + MovesSinceCapture: g.MovesSinceCapture, + } + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + if p := g.Board[r][c]; p != nil { + cp := *p + clone.Board[r][c] = &cp + } + } + } + if g.ChainPos != nil { + pos := *g.ChainPos + clone.ChainPos = &pos + } + if g.Result != nil { + res := *g.Result + clone.Result = &res + } + return clone +} + +// evaluateCheckers оценивает позицию с точки зрения perspective: +// положительно — хорошо для perspective, отрицательно — для +// соперника. +func evaluateCheckers(g *CheckersGameState, perspective CheckersColor) int { + if g.Result != nil { + if g.Result.Draw { + return 0 + } + if g.Result.Winner == perspective { + return 100000 + } + return -100000 + } + score := 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil { + continue + } + value := 10 + if p.King { + value = 18 + } else { + if p.Color == CheckersWhite { + value += (7 - r) / 3 + } else { + value += r / 3 + } + } + if p.Color == perspective { + score += value + } else { + score -= value + } + } + } + return score +} + +// minimax реализует альфа-бета отсечение. toMove — сторона, чей ход +// сейчас просчитывается; perspective — сторона, для которой ведётся +// итоговая оценка (бот). +func minimax(g *CheckersGameState, depth int, alpha, beta int, perspective CheckersColor) int { + if g.Result != nil || depth == 0 { + return evaluateCheckers(g, perspective) + } + + steps := legalStepsForColor(g, g.Turn) + if len(steps) == 0 { + return evaluateCheckers(g, perspective) + } + + maximizing := g.Turn == perspective + if maximizing { + best := math.MinInt32 + for _, step := range steps { + child := cloneCheckers(g) + _ = child.Move(step.From, step.To) + val := minimax(child, depth-1, alpha, beta, perspective) + if val > best { + best = val + } + if best > alpha { + alpha = best + } + if alpha >= beta { + break + } + } + return best + } + + best := math.MaxInt32 + for _, step := range steps { + child := cloneCheckers(g) + _ = child.Move(step.From, step.To) + val := minimax(child, depth-1, alpha, beta, perspective) + if val < best { + best = val + } + if best < beta { + beta = best + } + if alpha >= beta { + break + } + } + return best +} + +// DecideMove выбирает лучший ход для текущего хода в g согласно +// минимаксу. Возвращает (step, true), либо (_, false), если ходов +// нет (не должно происходить, если PlayFullTurn вызывается только +// когда игра не окончена). +func (b CheckersBot) DecideMove(g *CheckersGameState) (checkersMoveStep, bool) { + steps := legalStepsForColor(g, g.Turn) + if len(steps) == 0 { + return checkersMoveStep{}, false + } + depth := b.Depth + if depth <= 0 { + depth = defaultCheckersDepth + } + + perspective := g.Turn + bestVal := math.MinInt32 + var bestStep checkersMoveStep + alpha, beta := math.MinInt32, math.MaxInt32 + for _, step := range steps { + child := cloneCheckers(g) + _ = child.Move(step.From, step.To) + val := minimax(child, depth-1, alpha, beta, perspective) + if val > bestVal { + bestVal = val + bestStep = step + } + if bestVal > alpha { + alpha = bestVal + } + } + return bestStep, true +} + +// PlayFullTurn исполняет один шаг хода бота (одно применение Move) — +// при серии взятий вызывающий код должен продолжать вызывать +// PlayFullTurn, пока не сменится очередь хода (g.Turn). +func (b CheckersBot) PlayFullTurn(g *CheckersGameState) error { + step, ok := b.DecideMove(g) + if !ok { + return nil + } + return g.Move(step.From, step.To) +} diff --git a/checkers_bot_test.go b/checkers_bot_test.go new file mode 100644 index 0000000..faac9d3 --- /dev/null +++ b/checkers_bot_test.go @@ -0,0 +1,65 @@ +package main + +import "testing" + +// TestCheckersBotFullGames прогоняет несколько полных партий бота +// против самого себя (небольшая глубина поиска — ради скорости +// теста), проверяя отсутствие паник, зависаний и что итог всегда +// корректен (победа одной из сторон либо ничья по лимиту ходов). +func TestCheckersBotFullGames(t *testing.T) { + bot := CheckersBot{Depth: 3} + + for game := 0; game < 5; game++ { + g := NewCheckersGame() + + const maxSteps = 400 + steps := 0 + for ; steps < maxSteps && g.Result == nil; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("игра %d, шаг %d: ошибка бота: %v", game, steps, err) + } + } + if steps >= maxSteps { + t.Fatalf("игра %d: партия не завершилась за %d шагов (возможное зависание)", game, maxSteps) + } + if g.Result == nil { + t.Fatalf("игра %d: партия завершилась без результата", game) + } + + count := 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + if g.Board[r][c] != nil { + count++ + } + } + } + if count > 24 { + t.Fatalf("игра %d: на доске оказалось %d шашек — больше, чем было изначально", game, count) + } + } +} + +func TestCheckersBotHandlesMultiJumpChains(t *testing.T) { + bot := CheckersBot{Depth: 2} + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[6][1] = &CheckersPiece{Color: CheckersWhite} + g.Board[5][2] = &CheckersPiece{Color: CheckersBlack} + g.Board[3][4] = &CheckersPiece{Color: CheckersBlack} + + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("неожиданная ошибка первого шага: %v", err) + } + if g.Turn != CheckersWhite { + t.Fatalf("после первого взятия серии ход не должен был перейти сопернику") + } + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("неожиданная ошибка второго шага серии: %v", err) + } + if g.Turn != CheckersBlack { + t.Fatalf("после завершения серии взятий ход должен был перейти чёрным") + } + if g.Board[5][2] != nil || g.Board[3][4] != nil { + t.Errorf("обе шашки должны были быть взяты") + } +} diff --git a/checkers_game.go b/checkers_game.go new file mode 100644 index 0000000..c7b0acb --- /dev/null +++ b/checkers_game.go @@ -0,0 +1,327 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: реализованы русские шашки (не международные +// и не английские): доска 8х8, взятие обязательно (но правило +// "бить максимально" НЕ действует — подходит любое доступное взятие, +// в отличие от международных шашек), простая шашка бьёт в любом из +// четырёх диагональных направлений (включая назад), дамка ходит и +// бьёт "летающим" ходом на любое расстояние по диагонали. Официальные +// правила ничьей (15/30-ходовые ограничения для эндшпилей) не +// реализованы — вместо них простая защита: если долго (60 полуходов) +// не было взятий, партия принудительно объявляется ничьей. + +// CheckersColor — цвет стороны. +type CheckersColor int + +const ( + CheckersWhite CheckersColor = iota + CheckersBlack +) + +func (c CheckersColor) Opponent() CheckersColor { + if c == CheckersWhite { + return CheckersBlack + } + return CheckersWhite +} + +// CheckersPiece — шашка на доске. +type CheckersPiece struct { + Color CheckersColor + King bool +} + +// CheckersPos — координаты клетки (0-7, 0-7). +type CheckersPos struct { + Row, Col int +} + +func (p CheckersPos) inBounds() bool { + return p.Row >= 0 && p.Row < 8 && p.Col >= 0 && p.Col < 8 +} + +var ( + ErrCheckersWrongTurn = errors.New("сейчас не ваш ход") + ErrCheckersNoPiece = errors.New("на этой клетке нет вашей шашки") + ErrCheckersMustContinue = errors.New("нужно продолжать бить той же шашкой") + ErrCheckersInvalidMove = errors.New("такой ход недопустим") + ErrCheckersMustCapture = errors.New("доступно взятие — простой ход запрещён") + ErrCheckersGameOver = errors.New("партия уже завершена") +) + +// checkersMoveOption — один вариант хода для конкретной шашки: куда +// встать и (если это взятие) какая клетка при этом освобождается. +type checkersMoveOption struct { + To CheckersPos + Capture *CheckersPos +} + +// CheckersResult — итог завершившейся партии. +type CheckersResult struct { + Winner CheckersColor + Draw bool +} + +// maxCheckersMovesWithoutCapture — защита от бесконечных ничейных +// партий без взятий (упрощение официальных правил ничьей). +const maxCheckersMovesWithoutCapture = 60 + +// CheckersGameState — полное состояние партии в шашки. +type CheckersGameState struct { + Board [8][8]*CheckersPiece + Turn CheckersColor + ChainPos *CheckersPos // не nil, если середина серии взятий одной шашкой + MovesSinceCapture int + Result *CheckersResult +} + +// NewCheckersGame создаёт новую партию с начальной расстановкой: +// белые внизу (строки 5-7), чёрные наверху (строки 0-2), только на +// тёмных клетках. Первый ход — за белыми. +func NewCheckersGame() *CheckersGameState { + g := &CheckersGameState{Turn: CheckersWhite} + for row := 0; row < 3; row++ { + for col := 0; col < 8; col++ { + if (row+col)%2 == 1 { + g.Board[row][col] = &CheckersPiece{Color: CheckersBlack} + } + } + } + for row := 5; row < 8; row++ { + for col := 0; col < 8; col++ { + if (row+col)%2 == 1 { + g.Board[row][col] = &CheckersPiece{Color: CheckersWhite} + } + } + } + return g +} + +var checkersDirections = []CheckersPos{{Row: -1, Col: -1}, {Row: -1, Col: 1}, {Row: 1, Col: -1}, {Row: 1, Col: 1}} + +// simpleMoveOptions возвращает некапитальные ходы для шашки в pos. +func (g *CheckersGameState) simpleMoveOptions(pos CheckersPos) []CheckersPos { + piece := g.Board[pos.Row][pos.Col] + if piece == nil { + return nil + } + var out []CheckersPos + if piece.King { + for _, d := range checkersDirections { + for step := 1; ; step++ { + np := CheckersPos{Row: pos.Row + d.Row*step, Col: pos.Col + d.Col*step} + if !np.inBounds() || g.Board[np.Row][np.Col] != nil { + break + } + out = append(out, np) + } + } + return out + } + forward := 1 + if piece.Color == CheckersWhite { + forward = -1 + } + for _, dc := range []int{-1, 1} { + np := CheckersPos{Row: pos.Row + forward, Col: pos.Col + dc} + if np.inBounds() && g.Board[np.Row][np.Col] == nil { + out = append(out, np) + } + } + return out +} + +// captureMoveOptions возвращает доступные варианты взятия для шашки +// в pos (без учёта того, обязательно ли вообще бить в этот ход — +// это проверяется на уровне выше). +func (g *CheckersGameState) captureMoveOptions(pos CheckersPos) []checkersMoveOption { + piece := g.Board[pos.Row][pos.Col] + if piece == nil { + return nil + } + var out []checkersMoveOption + if piece.King { + for _, d := range checkersDirections { + var enemyAt *CheckersPos + for step := 1; ; step++ { + np := CheckersPos{Row: pos.Row + d.Row*step, Col: pos.Col + d.Col*step} + if !np.inBounds() { + break + } + occupant := g.Board[np.Row][np.Col] + if enemyAt == nil { + if occupant == nil { + continue + } + if occupant.Color == piece.Color { + break + } + captured := np + enemyAt = &captured + continue + } + if occupant != nil { + break + } + out = append(out, checkersMoveOption{To: np, Capture: enemyAt}) + } + } + return out + } + for _, d := range checkersDirections { + mid := CheckersPos{Row: pos.Row + d.Row, Col: pos.Col + d.Col} + land := CheckersPos{Row: pos.Row + d.Row*2, Col: pos.Col + d.Col*2} + if !land.inBounds() { + continue + } + midPiece := g.Board[mid.Row][mid.Col] + if midPiece == nil || midPiece.Color == piece.Color { + continue + } + if g.Board[land.Row][land.Col] != nil { + continue + } + out = append(out, checkersMoveOption{To: land, Capture: &mid}) + } + return out +} + +// anyCaptureAvailable проверяет, есть ли у стороны color хоть одно +// доступное взятие где-либо на доске. +func (g *CheckersGameState) anyCaptureAvailable(color CheckersColor) bool { + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p != nil && p.Color == color && len(g.captureMoveOptions(CheckersPos{Row: r, Col: c})) > 0 { + return true + } + } + } + return false +} + +// hasAnyLegalMove проверяет, есть ли у стороны color вообще хоть +// один легальный ход (взятие или простой). +func (g *CheckersGameState) hasAnyLegalMove(color CheckersColor) bool { + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil || p.Color != color { + continue + } + pos := CheckersPos{Row: r, Col: c} + if len(g.captureMoveOptions(pos)) > 0 || len(g.simpleMoveOptions(pos)) > 0 { + return true + } + } + } + return false +} + +// LegalMovesFrom возвращает список допустимых клеток назначения для +// шашки в pos с учётом текущей фазы хода (продолжение серии взятий, +// обязательное взятие или свободный ход). +func (g *CheckersGameState) LegalMovesFrom(pos CheckersPos) []checkersMoveOption { + if g.Result != nil { + return nil + } + piece := g.Board[pos.Row][pos.Col] + if piece == nil || piece.Color != g.Turn { + return nil + } + if g.ChainPos != nil { + if pos != *g.ChainPos { + return nil + } + return g.captureMoveOptions(pos) + } + if g.anyCaptureAvailable(g.Turn) { + return g.captureMoveOptions(pos) + } + simples := g.simpleMoveOptions(pos) + out := make([]checkersMoveOption, len(simples)) + for i, s := range simples { + out[i] = checkersMoveOption{To: s} + } + return out +} + +func isPromotionRow(color CheckersColor, row int) bool { + if color == CheckersWhite { + return row == 0 + } + return row == 7 +} + +// Move выполняет ход шашкой из from в to, если он допустим по +// текущим правилам (см. LegalMovesFrom). +func (g *CheckersGameState) Move(from, to CheckersPos) error { + if g.Result != nil { + return ErrCheckersGameOver + } + piece := g.Board[from.Row][from.Col] + if piece == nil || piece.Color != g.Turn { + return ErrCheckersNoPiece + } + if g.ChainPos != nil && from != *g.ChainPos { + return ErrCheckersMustContinue + } + + options := g.LegalMovesFrom(from) + var chosen *checkersMoveOption + for i := range options { + if options[i].To == to { + chosen = &options[i] + break + } + } + if chosen == nil { + if g.ChainPos == nil && g.anyCaptureAvailable(g.Turn) { + simples := g.simpleMoveOptions(from) + for _, s := range simples { + if s == to { + return ErrCheckersMustCapture + } + } + } + return ErrCheckersInvalidMove + } + + g.Board[from.Row][from.Col] = nil + if chosen.Capture != nil { + g.Board[chosen.Capture.Row][chosen.Capture.Col] = nil + } + g.Board[to.Row][to.Col] = piece + + if !piece.King && isPromotionRow(piece.Color, to.Row) { + piece.King = true + } + + if chosen.Capture != nil { + g.MovesSinceCapture = 0 + if len(g.captureMoveOptions(to)) > 0 { + g.ChainPos = &to + return nil + } + g.ChainPos = nil + } else { + g.MovesSinceCapture++ + g.ChainPos = nil + } + + g.Turn = g.Turn.Opponent() + g.checkGameOver() + return nil +} + +func (g *CheckersGameState) checkGameOver() { + if g.MovesSinceCapture >= maxCheckersMovesWithoutCapture { + g.Result = &CheckersResult{Draw: true} + return + } + if !g.hasAnyLegalMove(g.Turn) { + g.Result = &CheckersResult{Winner: g.Turn.Opponent()} + } +} diff --git a/checkers_game_test.go b/checkers_game_test.go new file mode 100644 index 0000000..2c204fd --- /dev/null +++ b/checkers_game_test.go @@ -0,0 +1,235 @@ +package main + +import "testing" + +func TestNewCheckersGameSetup(t *testing.T) { + g := NewCheckersGame() + whiteCount, blackCount := 0, 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil { + continue + } + if (r+c)%2 == 0 { + t.Errorf("шашка на светлой клетке (%d,%d)", r, c) + } + if p.Color == CheckersWhite { + whiteCount++ + } else { + blackCount++ + } + } + } + if whiteCount != 12 || blackCount != 12 { + t.Errorf("ожидалось по 12 шашек, получено white=%d black=%d", whiteCount, blackCount) + } + if g.Turn != CheckersWhite { + t.Errorf("первый ход должен быть за белыми") + } +} + +func TestSimpleManMoveForwardOnly(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[5][2] = &CheckersPiece{Color: CheckersWhite} + + if err := g.Move(CheckersPos{5, 2}, CheckersPos{4, 1}); err != nil { + t.Fatalf("неожиданная ошибка хода вперёд: %v", err) + } +} + +func TestSimpleManCannotMoveBackward(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[5][2] = &CheckersPiece{Color: CheckersWhite} + + if err := g.Move(CheckersPos{5, 2}, CheckersPos{6, 1}); err != ErrCheckersInvalidMove { + t.Errorf("простая шашка не должна ходить назад, ожидалась ErrCheckersInvalidMove, получено: %v", err) + } +} + +func TestManCapturesBackward(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[4][3] = &CheckersPiece{Color: CheckersWhite} + g.Board[5][4] = &CheckersPiece{Color: CheckersBlack} + + if err := g.Move(CheckersPos{4, 3}, CheckersPos{6, 5}); err != nil { + t.Fatalf("шашка должна была взять назад: %v", err) + } + if g.Board[5][4] != nil { + t.Errorf("взятая шашка должна была исчезнуть с доски") + } + if g.Board[6][5] == nil { + t.Errorf("шашка должна была оказаться на клетке приземления") + } +} + +func TestCaptureIsMandatory(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[5][2] = &CheckersPiece{Color: CheckersWhite} + g.Board[4][3] = &CheckersPiece{Color: CheckersBlack} + g.Board[5][6] = &CheckersPiece{Color: CheckersWhite} + + if err := g.Move(CheckersPos{5, 6}, CheckersPos{4, 5}); err != ErrCheckersMustCapture { + t.Errorf("простой ход должен быть запрещён при доступном взятии, получено: %v", err) + } + if err := g.Move(CheckersPos{5, 2}, CheckersPos{3, 4}); err != nil { + t.Fatalf("взятие должно было пройти: %v", err) + } +} + +func TestMultiJumpMustContinueWithSamePiece(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[6][1] = &CheckersPiece{Color: CheckersWhite} + g.Board[5][2] = &CheckersPiece{Color: CheckersBlack} + g.Board[3][4] = &CheckersPiece{Color: CheckersBlack} + g.Board[6][7] = &CheckersPiece{Color: CheckersWhite} // другая белая шашка — ходить ею сейчас нельзя + + if err := g.Move(CheckersPos{6, 1}, CheckersPos{4, 3}); err != nil { + t.Fatalf("первое взятие должно было пройти: %v", err) + } + if g.ChainPos == nil { + t.Fatalf("ожидалось продолжение серии взятий той же шашкой") + } + if g.Turn != CheckersWhite { + t.Errorf("ход не должен был перейти сопернику в середине серии") + } + + if err := g.Move(CheckersPos{6, 7}, CheckersPos{5, 6}); err != ErrCheckersMustContinue { + t.Errorf("ход другой шашкой в середине серии взятий должен быть запрещён, получено: %v", err) + } + + if err := g.Move(CheckersPos{4, 3}, CheckersPos{2, 5}); err != nil { + t.Fatalf("второе взятие в серии должно было пройти: %v", err) + } + if g.ChainPos != nil { + t.Errorf("серия должна была завершиться — больше взятий нет") + } + if g.Turn != CheckersBlack { + t.Errorf("ход должен был перейти чёрным после завершения серии") + } + if g.Board[5][2] != nil || g.Board[3][4] != nil { + t.Errorf("обе взятые шашки должны были исчезнуть с доски") + } +} + +func TestPromotionToKing(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[1][2] = &CheckersPiece{Color: CheckersWhite} + + if err := g.Move(CheckersPos{1, 2}, CheckersPos{0, 1}); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Board[0][1].King { + t.Errorf("шашка должна была стать дамкой на последней горизонтали") + } +} + +func TestPromotionDuringCaptureContinuesAsKing(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[2][1] = &CheckersPiece{Color: CheckersWhite} + g.Board[1][2] = &CheckersPiece{Color: CheckersBlack} + g.Board[2][5] = &CheckersPiece{Color: CheckersBlack} + + if err := g.Move(CheckersPos{2, 1}, CheckersPos{0, 3}); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Board[0][3].King { + t.Fatalf("шашка должна была стать дамкой при взятии на последней горизонтали") + } + if g.ChainPos == nil { + t.Fatalf("если дальнейшее взятие возможно дамкой — серия должна продолжиться") + } +} + +func TestKingFlyingMove(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[4][4] = &CheckersPiece{Color: CheckersWhite, King: true} + + if err := g.Move(CheckersPos{4, 4}, CheckersPos{1, 1}); err != nil { + t.Fatalf("дамка должна летать на любое расстояние по диагонали: %v", err) + } +} + +func TestKingFlyingCaptureWithChoiceOfLanding(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[7][0] = &CheckersPiece{Color: CheckersWhite, King: true} + g.Board[4][3] = &CheckersPiece{Color: CheckersBlack} + + options := g.captureMoveOptions(CheckersPos{7, 0}) + if len(options) == 0 { + t.Fatalf("ожидались варианты взятия дамкой") + } + landed := false + for _, opt := range options { + if opt.To == (CheckersPos{2, 5}) { + landed = true + } + } + if !landed { + t.Errorf("среди вариантов должна быть клетка (2,5)") + } + + if err := g.Move(CheckersPos{7, 0}, CheckersPos{1, 6}); err != nil { + t.Fatalf("выбор дальней клетки приземления должен быть допустим: %v", err) + } + if g.Board[4][3] != nil { + t.Errorf("взятая шашка должна была исчезнуть") + } +} + +func TestKingCannotJumpOverTwoPiecesInARow(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[7][0] = &CheckersPiece{Color: CheckersWhite, King: true} + g.Board[4][3] = &CheckersPiece{Color: CheckersBlack} + g.Board[3][4] = &CheckersPiece{Color: CheckersBlack} + + options := g.captureMoveOptions(CheckersPos{7, 0}) + for _, opt := range options { + if opt.To.Row <= 3 { + t.Errorf("нельзя приземлиться за второй подряд фигурой, но получен вариант %v", opt.To) + } + } +} + +func TestGameOverWhenNoLegalMoves(t *testing.T) { + g := &CheckersGameState{Turn: CheckersBlack} + g.Board[0][7] = &CheckersPiece{Color: CheckersBlack} + g.Board[1][6] = &CheckersPiece{Color: CheckersWhite} + // (2,5) занята — иначе чёрная шашка могла бы взять белую назад + // через прыжок (0,7)->(2,5), ведь простые шашки в русских шашках + // бьют в любом направлении, включая назад + g.Board[2][5] = &CheckersPiece{Color: CheckersWhite} + g.Board[5][0] = &CheckersPiece{Color: CheckersWhite} + + g.Turn = CheckersWhite + if err := g.Move(CheckersPos{5, 0}, CheckersPos{4, 1}); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil { + t.Fatalf("партия должна была завершиться — у чёрных нет ходов") + } + if g.Result.Winner != CheckersWhite { + t.Errorf("ожидался победитель — белые") + } +} + +func TestDrawByNoCaptureLimit(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite, MovesSinceCapture: maxCheckersMovesWithoutCapture - 1} + g.Board[5][2] = &CheckersPiece{Color: CheckersWhite} + + if err := g.Move(CheckersPos{5, 2}, CheckersPos{4, 1}); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil || !g.Result.Draw { + t.Fatalf("ожидалась ничья по лимиту ходов без взятия") + } +} + +func TestCannotMoveOpponentPiece(t *testing.T) { + g := &CheckersGameState{Turn: CheckersWhite} + g.Board[2][1] = &CheckersPiece{Color: CheckersBlack} + + if err := g.Move(CheckersPos{2, 1}, CheckersPos{3, 2}); err != ErrCheckersNoPiece { + t.Errorf("ожидалась ошибка хода чужой шашкой, получено: %v", err) + } +} diff --git a/checkers_tui.go b/checkers_tui.go new file mode 100644 index 0000000..1787969 --- /dev/null +++ b/checkers_tui.go @@ -0,0 +1,295 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + checkersDarkSquare = lipgloss.NewStyle().Background(lipgloss.Color("236")) + checkersLightSquare = lipgloss.NewStyle().Background(lipgloss.Color("94")) + checkersWhitePiece = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + checkersBlackPiece = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Bold(true) + checkersCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("220")).Foreground(lipgloss.Color("16")).Bold(true) + checkersSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("39")).Foreground(lipgloss.Color("16")).Bold(true) +) + +// CheckersModel — TUI поверх CheckersGameState. Человек всегда +// играет белыми (ходят первыми), бот — чёрными. +type CheckersModel struct { + game *CheckersGameState + bot CheckersBot + human CheckersColor + cursor CheckersPos + selected *CheckersPos + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewCheckersModel создаёт новую партию: человек играет белыми. +// depth — глубина поиска бота в полуходах (0 -> используется +// значение по умолчанию из CheckersBot). +func NewCheckersModel(depth int) CheckersModel { + return CheckersModel{ + game: NewCheckersGame(), + bot: CheckersBot{Depth: depth}, + human: CheckersWhite, + cursor: CheckersPos{Row: 5, Col: 0}, + } +} + +func (m CheckersModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +func (m CheckersModel) maybeScheduleBot() tea.Cmd { + if m.game.Result != nil { + return nil + } + if m.game.Turn == m.human { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *CheckersModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *CheckersModel) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m CheckersModel) 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 CheckersModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Result != nil || m.game.Turn == m.human { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Бот сходил.") + return m, m.maybeScheduleBot() +} + +func (m CheckersModel) 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.Result != nil { + if key == "n" { + m.game = NewCheckersGame() + m.selected = nil + m.message = "" + m.isError = false + return m, m.maybeScheduleBot() + } + return m, nil + } + + if m.game.Turn != m.human { + return m, nil + } + + switch key { + case "up", "k": + if m.cursor.Row > 0 { + m.cursor.Row-- + } + case "down", "j": + if m.cursor.Row < 7 { + m.cursor.Row++ + } + case "left", "h": + if m.cursor.Col > 0 { + m.cursor.Col-- + } + case "right", "l": + if m.cursor.Col < 7 { + m.cursor.Col++ + } + case "esc": + m.selected = nil + m.message = "" + case "enter", " ": + return m.handleSelectOrMove() + } + return m, nil +} + +func (m CheckersModel) handleSelectOrMove() (tea.Model, tea.Cmd) { + if m.selected == nil { + piece := m.game.Board[m.cursor.Row][m.cursor.Col] + if piece == nil || piece.Color != m.human { + m.setError(ErrCheckersNoPiece) + return m, nil + } + if len(m.game.LegalMovesFrom(m.cursor)) == 0 { + m.setError(ErrCheckersInvalidMove) + return m, nil + } + pos := m.cursor + m.selected = &pos + m.message = "" + return m, nil + } + + if *m.selected == m.cursor { + m.selected = nil + return m, nil + } + + from := *m.selected + err := m.game.Move(from, m.cursor) + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Ход выполнен.") + if m.game.ChainPos != nil { + pos := *m.game.ChainPos + m.selected = &pos + m.cursor = pos + } else { + m.selected = nil + } + return m, m.maybeScheduleBot() +} + +// --- Отрисовка ----------------------------------------------------- + +func pieceGlyph(p *CheckersPiece) string { + if p == nil { + return " " + } + glyph := "o" + if p.King { + glyph = "O" + } + style := checkersBlackPiece + if p.Color == CheckersWhite { + style = checkersWhitePiece + } + return style.Render(glyph) +} + +func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool { + set := map[CheckersPos]bool{} + if m.selected == nil { + return set + } + for _, opt := range m.game.LegalMovesFrom(*m.selected) { + set[opt.To] = true + } + return set +} + +func (m CheckersModel) renderBoard() string { + dests := m.legalDestinationSet() + var b strings.Builder + fmt.Fprintln(&b, " a b c d e f g h") + for r := 0; r < 8; r++ { + fmt.Fprintf(&b, "%d ", 8-r) + for c := 0; c < 8; c++ { + pos := CheckersPos{Row: r, Col: c} + cellStyle := checkersLightSquare + if (r+c)%2 == 1 { + cellStyle = checkersDarkSquare + } + glyph := pieceGlyph(m.game.Board[r][c]) + + switch { + case m.selected != nil && *m.selected == pos: + cellStyle = checkersSelStyle + case dests[pos]: + cellStyle = checkersSelStyle + case m.cursor == pos: + cellStyle = checkersCursorStyle + } + fmt.Fprint(&b, cellStyle.Render(" "+glyph+" ")) + } + fmt.Fprintln(&b) + } + return b.String() +} + +func (m CheckersModel) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== ШАШКИ ===")) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderBoard()) + fmt.Fprintln(&b) + + if m.game.Result != nil { + res := m.game.Result + switch { + case res.Draw: + fmt.Fprintln(&b, errorStyle.Render("Ничья (слишком долго не было взятий).")) + case res.Winner == m.human: + fmt.Fprintln(&b, winStyle.Render("Вы выиграли!")) + default: + fmt.Fprintln(&b, errorStyle.Render("Победил бот.")) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + return b.String() + } + + turnLabel := "Ваш ход (белые)" + if m.game.Turn != m.human { + turnLabel = "Бот думает (чёрные)..." + } + fmt.Fprintln(&b, turnLabel) + fmt.Fprintln(&b) + + if m.game.Turn == m.human { + if m.selected == nil { + fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] выбрать шашку [q] в меню") + } else { + fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] переместить (или отменить выбор) [esc] отменить выбор") + } + } + + 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() +} diff --git a/checkers_tui_test.go b/checkers_tui_test.go new file mode 100644 index 0000000..9754140 --- /dev/null +++ b/checkers_tui_test.go @@ -0,0 +1,134 @@ +package main + +import "testing" + +func TestCheckersModelSelectAndMoveViaKeys(t *testing.T) { + m := NewCheckersModel(0) + m.cursor = CheckersPos{Row: 5, Col: 2} + + next, _ := m.Update(key("enter")) + m = next.(CheckersModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора: %s", m.message) + } + if m.selected == nil { + t.Fatalf("ожидался выбор шашки") + } + + m.cursor = CheckersPos{Row: 4, Col: 1} + next, _ = m.Update(key("enter")) + m = next.(CheckersModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода: %s", m.message) + } + if m.game.Board[4][1] == nil { + t.Fatalf("шашка должна была переместиться") + } + if m.selected != nil { + t.Errorf("выбор должен был сброситься после хода") + } +} + +func TestCheckersModelCursorMovement(t *testing.T) { + m := NewCheckersModel(0) + m.cursor = CheckersPos{Row: 4, Col: 4} + + next, _ := m.Update(key("up")) + m = next.(CheckersModel) + if m.cursor.Row != 3 { + t.Errorf("курсор должен был сдвинуться вверх") + } + next, _ = m.Update(key("right")) + m = next.(CheckersModel) + if m.cursor.Col != 5 { + t.Errorf("курсор должен был сдвинуться вправо") + } +} + +func TestCheckersModelEscClearsSelection(t *testing.T) { + m := NewCheckersModel(0) + m.cursor = CheckersPos{Row: 5, Col: 2} + next, _ := m.Update(key("enter")) + m = next.(CheckersModel) + if m.selected == nil { + t.Fatalf("ожидался выбор") + } + + next, _ = m.Update(key("esc")) + m = next.(CheckersModel) + if m.selected != nil { + t.Errorf("esc должен был сбросить выбор") + } +} + +func TestCheckersModelCannotSelectEmptySquare(t *testing.T) { + m := NewCheckersModel(0) + m.cursor = CheckersPos{Row: 3, Col: 2} + + next, _ := m.Update(key("enter")) + m = next.(CheckersModel) + if !m.isError { + t.Fatalf("ожидалась ошибка выбора пустой клетки") + } +} + +func TestCheckersModelQReturnsToMenu(t *testing.T) { + m := NewCheckersModel(0) + next, _ := m.Update(key("q")) + m = next.(CheckersModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestCheckersModelNewGameAfterResult(t *testing.T) { + m := NewCheckersModel(0) + m.game.Result = &CheckersResult{Winner: CheckersWhite} + + next, _ := m.Update(key("n")) + m = next.(CheckersModel) + if m.game.Result != nil { + t.Errorf("новая партия не должна была начаться с результатом") + } +} + +// TestCheckersModelFullAutoPlaythrough прогоняет TUI-модель от +// начала до конца, где "человек" отыгрывает свои ходы через бота +// (небольшая глубина — ради скорости), проверяя, что весь путь +// через Update не паникует и партия завершается. +func TestCheckersModelFullAutoPlaythrough(t *testing.T) { + m := NewCheckersModel(0) + m.bot = CheckersBot{Depth: 2} + helperBot := CheckersBot{Depth: 2} + + const maxSteps = 400 + for i := 0; i < maxSteps && m.game.Result == nil; i++ { + if m.game.Turn != m.human { + next, _ := m.Update(botMoveMsg{}) + m = next.(CheckersModel) + continue + } + mv, ok := helperBot.DecideMove(m.game) + if !ok { + break + } + m.cursor = mv.From + next, _ := m.Update(key("enter")) + m = next.(CheckersModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора хода человеком: %s", m.message) + } + m.cursor = mv.To + next, _ = m.Update(key("enter")) + m = next.(CheckersModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода человеком: %s", m.message) + } + } + if m.game.Result == nil { + t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) + } +} diff --git a/deck.go b/deck.go new file mode 100644 index 0000000..ba5b28c --- /dev/null +++ b/deck.go @@ -0,0 +1,65 @@ +package main + +import ( + "crypto/rand" + "math/big" +) + +// Deck — стопка карт. Индекс 0 — верх колоды (карта, которая уходит первой). +type Deck struct { + cards []Card +} + +// NewDeck создаёт полную неперетасованную колоду из 52 карт. +func NewDeck() *Deck { + d := &Deck{cards: make([]Card, 0, 52)} + for s := Clubs; s <= Spades; s++ { + for r := Ace; r <= King; r++ { + d.cards = append(d.cards, Card{Suit: s, Rank: r}) + } + } + return d +} + +// Shuffle перемешивает колоду криптографически стойким рандомом +// (алгоритм Фишера-Йейтса). +func (d *Deck) Shuffle() { + for i := len(d.cards) - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + // crypto/rand не должен падать в обычных условиях; + // если это случилось — не имеет смысла продолжать игру. + panic(err) + } + j := int(jBig.Int64()) + d.cards[i], d.cards[j] = d.cards[j], d.cards[i] + } +} + +// Len возвращает количество карт, оставшихся в колоде. +func (d *Deck) Len() int { + return len(d.cards) +} + +// Draw снимает верхнюю карту колоды. Второе значение — false, +// если колода пуста. +func (d *Deck) Draw() (Card, bool) { + if len(d.cards) == 0 { + return Card{}, false + } + c := d.cards[0] + d.cards = d.cards[1:] + return c, true +} + +// DrawN снимает n верхних карт (используется при раздаче). +// Если в колоде меньше карт, чем запрошено, возвращает столько, +// сколько есть. +func (d *Deck) DrawN(n int) []Card { + if n > len(d.cards) { + n = len(d.cards) + } + out := d.cards[:n] + d.cards = d.cards[n:] + return out +} diff --git a/durak_bot.go b/durak_bot.go new file mode 100644 index 0000000..0ff3277 --- /dev/null +++ b/durak_bot.go @@ -0,0 +1,177 @@ +package main + +import "math/rand" + +// durakNamePool — имена ботов в Дураке. Отдельный пул от Тонка +// (там прозвища, здесь — обычные русские имена), но по той же +// логике: уровень игры бота нигде не отражается в имени, а он тут +// и всего один (DurakBot{}) — градации сложности нет. +var durakNamePool = []string{ + "Иван", "Пётр", "Алексей", "Дмитрий", "Сергей", "Николай", "Андрей", "Михаил", +} + +// pickDurakBotName выбирает случайное имя из durakNamePool, по +// возможности не повторяющее уже занятые (used). +func pickDurakBotName(used map[string]bool) string { + free := make([]string, 0, len(durakNamePool)) + for _, name := range durakNamePool { + if !used[name] { + free = append(free, name) + } + } + if len(free) == 0 { + free = durakNamePool + } + return free[rand.Intn(len(free))] +} + +// newDurakBotNames возвращает n случайных (без повторов, если +// хватает пула) имён ботов для Дурака. +func newDurakBotNames(n int) []string { + used := make(map[string]bool, n) + names := make([]string, n) + for i := 0; i < n; i++ { + name := pickDurakBotName(used) + used[name] = true + names[i] = name + } + return names +} + +// DurakBot — единственная (без градации уровней) стратегия бота +// для Дурака: разумная, но не идеальная игра. Бережёт козыри, +// старается не подставляться и подкидывает только тогда, когда это +// низкорисково. +type DurakBot struct{} + +// DecideAttack решает, какой картой открыть раунд. Вызывается +// только в фазе DurakPhaseAttack — это обязательный первый ход, +// отказаться нельзя, поэтому второе возвращаемое значение всегда +// true, если на руке вообще есть карты (иначе бот не должен был +// стать атакующим в принципе). +func (DurakBot) DecideAttack(g *DurakGameState) (Card, bool) { + hand := g.attacker().Hand + if len(hand) == 0 { + return Card{}, false + } + return weakestCard(hand, g.TrumpSuit, true), true +} + +// DecideThrowOrPass решает, что делать игроку, чья сейчас очередь в +// фазе DurakPhaseThrowIn: подкинуть карту подходящего ранга или +// пасовать. Возвращает (card, true), если нужно подкинуть card, +// иначе (_, false), если нужно пасовать (вызывающий код вызывает +// g.Pass()). +func (DurakBot) DecideThrowOrPass(g *DurakGameState) (Card, bool) { + throwerIdx := g.CurrentThrower() + if throwerIdx == -1 { + return Card{}, false + } + hand := g.Players[throwerIdx].Hand + if len(g.Table) >= g.attackCap { + return Card{}, false + } + + var candidates []Card + for _, c := range hand { + if g.tableHasRank(c.Rank) { + candidates = append(candidates, c) + } + } + if len(candidates) == 0 { + return Card{}, false + } + return weakestCard(candidates, g.TrumpSuit, true), true +} + +// DecideDefend решает, чем отбиваться от последней неотбитой карты +// на столе. Возвращает (card, true), если нужно отбить card, иначе +// (_, false), если нужно забрать стол (вызывающий код вызывает +// g.Take()). Вызывать имеет смысл только когда Phase == +// DurakPhaseDefend. +func (DurakBot) DecideDefend(g *DurakGameState) (Card, bool) { + if len(g.Table) == 0 { + return Card{}, false + } + attackCard := g.Table[len(g.Table)-1].Attack + hand := g.defender().Hand + + var best *Card + for i, c := range hand { + if !durakBeats(c, attackCard, g.TrumpSuit) { + continue + } + if best == nil || durakDefendCost(c, g.TrumpSuit) < durakDefendCost(*best, g.TrumpSuit) { + cc := hand[i] + best = &cc + } + } + if best == nil { + return Card{}, false + } + return *best, true +} + +// weakestCard находит самую "дешёвую" карту в наборе: если +// preferNonTrump=true и есть некозырные карты, ищет минимум среди +// них; иначе ищет минимум по всему набору. "Минимум" — по +// старшинству в Дураке (durakRankOrder). +func weakestCard(cards []Card, trump Suit, preferNonTrump bool) Card { + pool := cards + if preferNonTrump { + var nonTrump []Card + for _, c := range cards { + if c.Suit != trump { + nonTrump = append(nonTrump, c) + } + } + if len(nonTrump) > 0 { + pool = nonTrump + } + } + best := pool[0] + for _, c := range pool[1:] { + if durakRankOrder[c.Rank] < durakRankOrder[best.Rank] { + best = c + } + } + return best +} + +// durakDefendCost оценивает "стоимость" использования карты для +// защиты: некозырные карты дешевле козырей (козыри — ценный +// ресурс, тратить их на отбой лишний раз не хочется), а внутри +// каждой категории дешевле карты младше. +func durakDefendCost(c Card, trump Suit) int { + base := durakRankOrder[c.Rank] + if c.Suit == trump { + return base + 100 // козырь всегда "дороже" любой некозырной карты + } + return base +} + +// PlayFullTurn исполняет одно решение бота: подкинуть/отбиться/ +// пасовать/забрать стол — используется при автоматической прокрутке +// партии. Возвращает ошибку только если что-то пошло не так на +// уровне игровой логики (не должно случаться при корректной работе +// решающих функций). +func (b DurakBot) PlayFullTurn(g *DurakGameState) error { + switch g.Phase { + case DurakPhaseAttack: + if card, ok := b.DecideAttack(g); ok { + return g.ThrowIn(card) + } + return nil // не должно происходить: у атакующего не может не быть карт + case DurakPhaseDefend: + if card, ok := b.DecideDefend(g); ok { + return g.Defend(card) + } + return g.Take() + case DurakPhaseThrowIn: + if card, ok := b.DecideThrowOrPass(g); ok { + return g.ThrowIn(card) + } + return g.Pass() + } + return nil +} diff --git a/durak_bot_test.go b/durak_bot_test.go new file mode 100644 index 0000000..88679a8 --- /dev/null +++ b/durak_bot_test.go @@ -0,0 +1,99 @@ +package main + +import "testing" + +func TestStalemateProtectionTerminatesGame(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + g.Talon = nil + g.TrumpSuit = Spades + g.AttackerIdx = 0 + g.DefenderIdx = 1 + // у обоих всегда есть по карте, а защищающийся никогда не может + // отбиться (у него всегда некозырная карта младше атакующей) — + // имитируем принудительное "взятие" много раз подряд напрямую + for i := 0; i < maxTakesWithoutBito+5 && g.Phase != DurakPhaseGameOver; i++ { + g.Players[0].Hand = []Card{c(Eight, Clubs), c(Nine, Diamonds)} // запасная карта, чтобы не выбыть + g.Players[1].Hand = []Card{c(Six, Hearts)} + g.attackCap = g.currentAttackCap() + g.Phase = DurakPhaseAttack + if err := g.ThrowIn(c(Eight, Clubs)); err != nil { + t.Fatalf("шаг %d: неожиданная ошибка подкидывания: %v", i, err) + } + if err := g.Take(); err != nil { + t.Fatalf("шаг %d: неожиданная ошибка взятия: %v", i, err) + } + if g.Phase != DurakPhaseGameOver { + // перед следующей итерацией восстанавливаем роли и стол + // вручную — в реальной игре это происходит по правилам + g.AttackerIdx, g.DefenderIdx = 0, 1 + } + } + if g.Phase != DurakPhaseGameOver { + t.Fatalf("партия должна была принудительно завершиться из-за защиты от зацикливания") + } + if g.Result == nil || !g.Result.Stalemate { + t.Fatalf("ожидался результат Stalemate, получено: %+v", g.Result) + } +} + +// TestDurakBotFullGames прогоняет много полных партий силами +// DurakBot для разного числа игроков (2-6) и проверяет, что игра +// всегда корректно завершается: без паники, без зависания, и с +// валидным результатом (ровно один проигравший либо ничья). +func TestDurakBotFullGames(t *testing.T) { + bot := DurakBot{} + + for numPlayers := 2; numPlayers <= 6; numPlayers++ { + for game := 0; game < 30; game++ { + names := make([]string, numPlayers) + for i := range names { + names[i] = "Bot" + } + g := NewDurakGame(names, -1) + + const maxSteps = 10000 + steps := 0 + for ; steps < maxSteps && g.Phase != DurakPhaseGameOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("игроков %d, игра %d, шаг %d: ошибка бота: %v", + numPlayers, game, steps, err) + } + } + + if steps >= maxSteps { + t.Fatalf("игроков %d, игра %d: партия не завершилась за %d шагов (возможное зависание)", + numPlayers, game, maxSteps) + } + if g.Result == nil { + t.Fatalf("игроков %d, игра %d: партия завершилась без результата", numPlayers, game) + } + if !g.Result.NoLoser && !g.Result.Stalemate { + if g.Result.Loser < 0 || g.Result.Loser >= numPlayers { + t.Fatalf("игроков %d, игра %d: некорректный индекс проигравшего %d", + numPlayers, game, g.Result.Loser) + } + } + + // суммарно карты не должны теряться и не должны + // дублироваться: в талоне + на руках должно остаться + // ровно то, что не ушло в "бито" (отбой). Проверяем + // более простой инвариант — отсутствие дубликатов среди + // всех карт, которые сейчас на руках и в талоне. + seen := map[Card]bool{} + for _, p := range g.Players { + for _, c := range p.Hand { + if seen[c] { + t.Fatalf("игроков %d, игра %d: карта %v встретилась дважды", numPlayers, game, c) + } + seen[c] = true + } + } + for _, c := range g.Talon { + if seen[c] { + t.Fatalf("игроков %d, игра %d: карта %v встретилась дважды (в талоне и на руке)", numPlayers, game, c) + } + seen[c] = true + } + } + } +} diff --git a/durak_deck.go b/durak_deck.go new file mode 100644 index 0000000..e7289be --- /dev/null +++ b/durak_deck.go @@ -0,0 +1,34 @@ +package main + +// NewDurakDeck создаёт колоду для Дурака: 36 карт (от шестёрки до +// туза), без карт от двойки до пятёрки. Использует те же типы +// Suit/Rank/Card, что и остальные игры коллекции. +func NewDurakDeck() *Deck { + ranks := []Rank{Six, Seven, Eight, Nine, Ten, Jack, Queen, King, Ace} + d := &Deck{cards: make([]Card, 0, 36)} + for s := Clubs; s <= Spades; s++ { + for _, r := range ranks { + d.cards = append(d.cards, Card{Suit: s, Rank: r}) + } + } + return d +} + +// durakRankOrder — старшинство рангов в Дураке: шестёрка младше +// всех, туз — старше всех. В отличие от Rank.Value() (очковая +// стоимость карты в Тонке), здесь важен только порядок. +var durakRankOrder = map[Rank]int{ + Six: 0, Seven: 1, Eight: 2, Nine: 3, Ten: 4, + Jack: 5, Queen: 6, King: 7, Ace: 8, +} + +// durakBeats проверяет, бьёт ли карта defend карту attack при +// козыре trump: старше по рангу той же масти, либо любой козырь +// против некозырной карты. Козырь козырем бьётся только старшим +// козырем — простая, но не менее важная деталь. +func durakBeats(defend, attack Card, trump Suit) bool { + if defend.Suit == attack.Suit { + return durakRankOrder[defend.Rank] > durakRankOrder[attack.Rank] + } + return defend.Suit == trump && attack.Suit != trump +} diff --git a/durak_game.go b/durak_game.go new file mode 100644 index 0000000..5cd63ee --- /dev/null +++ b/durak_game.go @@ -0,0 +1,538 @@ +package main + +import "errors" + +// DurakPlayer — состояние одного игрока в партии Дурак. +type DurakPlayer struct { + Name string + Hand []Card + IsHuman bool + Out bool // "отбился насовсем": карт нет и колода пуста +} + +// durakTablePair — одна атакующая карта на столе и (если есть) +// карта, которой её отбили. +type durakTablePair struct { + Attack Card + Defend *Card +} + +// DurakPhase — чья сейчас очередь действовать. +type DurakPhase int + +const ( + // DurakPhaseAttack — самый первый ход раунда: атакующий обязан + // подкинуть карту (стол ещё пуст, выбора "пасовать" нет). + DurakPhaseAttack DurakPhase = iota + // DurakPhaseDefend — защищающийся должен отбить последнюю + // подкинутую карту либо забрать весь стол. + DurakPhaseDefend + // DurakPhaseThrowIn — стол полностью отбит; по очереди (см. + // ThrowOrder/ThrowTurnPos) любой игрок, кроме защищающегося, + // может либо подкинуть ещё одну карту подходящего ранга, либо + // пасовать. Как только все подряд спасовали (или лимит атаки + // исчерпан) — раунд уходит в отбой. + DurakPhaseThrowIn + // DurakPhaseGameOver — партия завершена. + DurakPhaseGameOver +) + +var ( + ErrDurakWrongPhase = errors.New("недопустимое действие для текущей фазы хода") + ErrDurakCardNotInHand = errors.New("карты нет на руке игрока") + ErrDurakInvalidThrowIn = errors.New("эту карту нельзя подкинуть: не тот ранг или лимит атаки исчерпан") + ErrDurakInvalidDefend = errors.New("эта карта не бьёт атакующую карту") + ErrDurakNothingToDefend = errors.New("на столе нет карты, которую нужно отбивать") + ErrDurakEmptyTable = errors.New("на столе нет карт") +) + +// DurakResult — итог завершившейся партии. +type DurakResult struct { + Loser int // индекс проигравшего ("дурака"); не имеет смысла при NoLoser/Stalemate + NoLoser bool // все игроки одновременно избавились от карт — без проигравшего + Stalemate bool // партия принудительно завершена вничью защитой от зацикливания +} + +// DurakGameState — полное состояние партии в Дурак. +type DurakGameState struct { + Players []*DurakPlayer + + Talon []Card // колода для добора; Talon[0] — верхняя (берётся первой) + TrumpSuit Suit + TrumpCard Card // вскрытая козырная карта — последняя в Talon, показывается отдельно + + AttackerIdx int + DefenderIdx int + Table []durakTablePair + Phase DurakPhase + Result *DurakResult + + // attackCap — сколько карт максимум может оказаться на столе в + // этом раунде: min(6, размер руки защищающегося на момент + // начала раунда). Фиксируется один раз при начале раунда. + attackCap int + + // ThrowOrder — порядок подкидывающих в текущем раунде: все + // активные игроки, кроме защищающегося, начиная с атакующего, по + // кругу. Вычисляется один раз при начале раунда. + ThrowOrder []int + // ThrowTurnPos — индекс в ThrowOrder, чья сейчас очередь решать + // "подкинуть или пасовать" в фазе DurakPhaseThrowIn. + ThrowTurnPos int + // passedSinceThrow считает подряд идущие пасы (без единого + // подкидывания между ними) в текущей серии решений. Когда оно + // достигает len(ThrowOrder), все отказались — раунд уходит в + // отбой. + passedSinceThrow int + + // takesSinceBito считает подряд идущие завершения раундов через + // "взятие" (без единого "бито" между ними). Когда карты на столе + // не выходят из игры (только перекладываются между руками), + // партия теоретически может зациклиться навсегда — это защита + // от такого зацикливания. + takesSinceBito int +} + +// maxTakesWithoutBito — порог принудительного завершения партии +// вничью (Stalemate), если очень долго не происходит ни одного +// "бито" подряд (карты не выходят из игры). +const maxTakesWithoutBito = 200 + +// NewDurakGame создаёт новую партию: тасует колоду на 36 карт, +// раздаёт по 6 карт (кому-то может достаться меньше — например, при +// 6 игроках колода не делится ровно), вскрывает козырь и определяет +// первого атакующего (у кого на руке самый младший козырь). +func NewDurakGame(names []string, humanIdx int) *DurakGameState { + deck := NewDurakDeck() + deck.Shuffle() + + trumpCard := deck.cards[len(deck.cards)-1] + + players := make([]*DurakPlayer, len(names)) + for i, name := range names { + players[i] = &DurakPlayer{Name: name, IsHuman: i == humanIdx} + } + + g := &DurakGameState{ + Players: players, + Talon: deck.cards, + TrumpSuit: trumpCard.Suit, + TrumpCard: trumpCard, + } + + // начальная раздача: раунд-робин по одной карте, пока у всех не + // станет по 6 либо в колоде не останется только козырная карта + for dealt := true; dealt; { + dealt = false + for _, p := range g.Players { + if len(p.Hand) < 6 && len(g.Talon) > 1 { + p.Hand = append(p.Hand, g.drawReserveTrump()) + dealt = true + } + } + } + + g.AttackerIdx = g.findInitialAttacker() + g.DefenderIdx = g.nextActive(g.AttackerIdx) + g.startNewBout() + return g +} + +// startNewBout готовит служебные поля к началу нового раунда: +// лимит атаки и порядок подкидывающих, всегда исходя из текущих +// AttackerIdx/DefenderIdx. +func (g *DurakGameState) startNewBout() { + g.attackCap = g.currentAttackCap() + g.ThrowOrder = g.computeThrowOrder() + g.ThrowTurnPos = 0 + g.passedSinceThrow = 0 + g.Phase = DurakPhaseAttack +} + +// computeThrowOrder строит порядок подкидывающих: все активные +// игроки, кроме защищающегося, начиная с атакующего, по кругу. +func (g *DurakGameState) computeThrowOrder() []int { + order := []int{} + idx := g.AttackerIdx + for { + if idx != g.DefenderIdx { + order = append(order, idx) + } + next := g.nextActive(idx) + if next == g.AttackerIdx { + break + } + idx = next + if len(order) > len(g.Players) { + break // защита от зацикливания в вырожденных случаях + } + } + return order +} + +// drawReserveTrump снимает верхнюю карту талона, никогда не трогая +// последнюю (козырную) карту, пока в талоне есть что-то ещё — +// используется только при начальной раздаче. +func (g *DurakGameState) drawReserveTrump() Card { + c := g.Talon[0] + g.Talon = g.Talon[1:] + return c +} + +// drawAny снимает верхнюю карту талона, включая последнюю +// (козырную), если до неё дошла очередь — используется при доборе +// карт между раундами. +func (g *DurakGameState) drawAny() Card { + c := g.Talon[0] + g.Talon = g.Talon[1:] + return c +} + +// findInitialAttacker находит игрока с самым младшим козырем на +// руке — он ходит первым. Если ни у кого нет козыря (редкий случай +// при небольшом числе игроков), первым ходит игрок 0. +func (g *DurakGameState) findInitialAttacker() int { + best := -1 + bestRank := 99 + for i, p := range g.Players { + for _, c := range p.Hand { + if c.Suit == g.TrumpSuit && durakRankOrder[c.Rank] < bestRank { + bestRank = durakRankOrder[c.Rank] + best = i + } + } + } + if best == -1 { + return 0 + } + return best +} + +// nextActive возвращает индекс следующего по кругу игрока, который +// ещё не вышел из игры (Out == false). +func (g *DurakGameState) nextActive(from int) int { + n := len(g.Players) + for i := 1; i <= n; i++ { + idx := (from + i) % n + if !g.Players[idx].Out { + return idx + } + } + return from +} + +func (g *DurakGameState) attacker() *DurakPlayer { return g.Players[g.AttackerIdx] } +func (g *DurakGameState) defender() *DurakPlayer { return g.Players[g.DefenderIdx] } + +func (g *DurakGameState) currentAttackCap() int { + capacity := 6 + if len(g.defender().Hand) < capacity { + capacity = len(g.defender().Hand) + } + return capacity +} + +// CurrentThrower возвращает индекс игрока, чья сейчас очередь +// решать "подкинуть или пасовать" в фазе DurakPhaseThrowIn. Если +// подкидывать сейчас некому (ThrowOrder пуст — вырожденный случай), +// возвращает -1. +func (g *DurakGameState) CurrentThrower() int { + if len(g.ThrowOrder) == 0 { + return -1 + } + return g.ThrowOrder[g.ThrowTurnPos] +} + +func hasCard(hand []Card, c Card) bool { + for _, card := range hand { + if card == c { + return true + } + } + return false +} + +func removeCard(hand []Card, c Card) []Card { + for i, card := range hand { + if card == c { + return append(hand[:i], hand[i+1:]...) + } + } + return hand +} + +// tableHasRank проверяет, встречается ли ранг card среди уже +// сыгранных на столе карт (атакующих и отбивающих) — именно такими +// картами можно подкидывать. +func (g *DurakGameState) tableHasRank(r Rank) bool { + for _, pair := range g.Table { + if pair.Attack.Rank == r { + return true + } + if pair.Defend != nil && pair.Defend.Rank == r { + return true + } + } + return false +} + +// ThrowIn подкидывает карту на стол. В фазе DurakPhaseAttack это +// обязательный первый ход раунда (действует атакующий, стол ещё +// пуст). В фазе DurakPhaseThrowIn действует игрок из ThrowOrder, +// чья сейчас очередь (CurrentThrower) — карта должна быть рангом, +// уже встречающимся на столе. +func (g *DurakGameState) ThrowIn(card Card) error { + switch g.Phase { + case DurakPhaseAttack: + return g.throwInAsAttacker(card) + case DurakPhaseThrowIn: + return g.throwInAsThrower(card) + default: + return ErrDurakWrongPhase + } +} + +func (g *DurakGameState) throwInAsAttacker(card Card) error { + if !hasCard(g.attacker().Hand, card) { + return ErrDurakCardNotInHand + } + if len(g.Table) >= g.attackCap { + return ErrDurakInvalidThrowIn + } + g.attacker().Hand = removeCard(g.attacker().Hand, card) + g.Table = append(g.Table, durakTablePair{Attack: card}) + g.Phase = DurakPhaseDefend + return nil +} + +func (g *DurakGameState) throwInAsThrower(card Card) error { + throwerIdx := g.CurrentThrower() + if throwerIdx == -1 { + return ErrDurakWrongPhase + } + thrower := g.Players[throwerIdx] + if !hasCard(thrower.Hand, card) { + return ErrDurakCardNotInHand + } + if !g.tableHasRank(card.Rank) { + return ErrDurakInvalidThrowIn + } + if len(g.Table) >= g.attackCap { + return ErrDurakInvalidThrowIn + } + + thrower.Hand = removeCard(thrower.Hand, card) + g.Table = append(g.Table, durakTablePair{Attack: card}) + g.passedSinceThrow = 0 + g.advanceThrowTurn() + g.Phase = DurakPhaseDefend + return nil +} + +// Pass — игрок, чья сейчас очередь в фазе DurakPhaseThrowIn, +// отказывается подкидывать. Если после этого все по кругу подряд +// отказались (или желающих подкидывать не осталось) — раунд уходит +// в отбой. +func (g *DurakGameState) Pass() error { + if g.Phase != DurakPhaseThrowIn { + return ErrDurakWrongPhase + } + if len(g.ThrowOrder) == 0 { + g.resolveBito() + return nil + } + g.passedSinceThrow++ + g.advanceThrowTurn() + if g.passedSinceThrow >= len(g.ThrowOrder) { + g.resolveBito() + } + return nil +} + +func (g *DurakGameState) advanceThrowTurn() { + if len(g.ThrowOrder) == 0 { + return + } + g.ThrowTurnPos = (g.ThrowTurnPos + 1) % len(g.ThrowOrder) +} + +// enterThrowInPhase переводит раунд в фазу подкидывания после +// успешной защиты — либо сразу завершает раунд отбоем, если лимит +// атаки уже исчерпан или подкидывать больше некому. +func (g *DurakGameState) enterThrowInPhase() { + if len(g.Table) >= g.attackCap || len(g.ThrowOrder) == 0 { + g.resolveBito() + return + } + g.Phase = DurakPhaseThrowIn +} + +// Defend — защищающийся отбивает последнюю подкинутую карту. +func (g *DurakGameState) Defend(card Card) error { + if g.Phase != DurakPhaseDefend { + return ErrDurakWrongPhase + } + if len(g.Table) == 0 { + return ErrDurakNothingToDefend + } + last := &g.Table[len(g.Table)-1] + if last.Defend != nil { + return ErrDurakNothingToDefend + } + if !hasCard(g.defender().Hand, card) { + return ErrDurakCardNotInHand + } + if !durakBeats(card, last.Attack, g.TrumpSuit) { + return ErrDurakInvalidDefend + } + + g.defender().Hand = removeCard(g.defender().Hand, card) + last.Defend = &card + g.enterThrowInPhase() + return nil +} + +// resolveBito завершает раунд "бито": все карты на столе выходят из +// игры навсегда, роли переходят по кругу от защищающегося. +func (g *DurakGameState) resolveBito() { + oldAttacker, oldDefender := g.AttackerIdx, g.DefenderIdx + g.Table = nil + g.takesSinceBito = 0 + + refillOrder := g.refillOrderFrom(oldAttacker, oldDefender) + g.refill(refillOrder) + + g.finishBout(oldDefender) +} + +// Take — защищающийся забирает весь стол себе в руку. +func (g *DurakGameState) Take() error { + if g.Phase != DurakPhaseDefend { + return ErrDurakWrongPhase + } + if len(g.Table) == 0 { + return ErrDurakEmptyTable + } + + for _, pair := range g.Table { + g.defender().Hand = append(g.defender().Hand, pair.Attack) + if pair.Defend != nil { + g.defender().Hand = append(g.defender().Hand, *pair.Defend) + } + } + oldAttacker, oldDefender := g.AttackerIdx, g.DefenderIdx + g.Table = nil + g.takesSinceBito++ + + if g.takesSinceBito >= maxTakesWithoutBito { + g.Result = &DurakResult{Stalemate: true} + g.Phase = DurakPhaseGameOver + return nil + } + + // защищающийся забрал карты — добор ему не нужен, он и так с + // полной рукой; добирают все остальные, начиная с атакующего + refillOrder := g.refillOrderFrom(oldAttacker, oldDefender) + filtered := refillOrder[:0] + for _, idx := range refillOrder { + if idx != oldDefender { + filtered = append(filtered, idx) + } + } + g.refill(filtered) + + g.finishBoutAfterTake(oldDefender) + return nil +} + +// refillOrderFrom строит порядок добора карт: начиная с атакующего +// раунда, по кругу, заканчивая защищающимся — учитывая только ещё +// не выбывших игроков. +func (g *DurakGameState) refillOrderFrom(attackerIdx, defenderIdx int) []int { + order := []int{} + seen := map[int]bool{} + idx := attackerIdx + for { + if !g.Players[idx].Out && !seen[idx] { + order = append(order, idx) + seen[idx] = true + } + if idx == defenderIdx { + break + } + idx = g.nextActive(idx) + if len(order) > len(g.Players) { + break // защита от зацикливания в вырожденных случаях + } + } + return order +} + +func (g *DurakGameState) refill(order []int) { + for _, idx := range order { + p := g.Players[idx] + for len(p.Hand) < 6 && len(g.Talon) > 0 { + p.Hand = append(p.Hand, g.drawAny()) + } + } +} + +// finishBout завершает раунд по "бито": роли переходят к следующей +// паре игроков по кругу от старого защищающегося. +func (g *DurakGameState) finishBout(oldDefenderIdx int) { + g.markOutPlayers() + if g.checkGameOver() { + return + } + g.AttackerIdx = g.nextActive(oldDefenderIdx) + g.DefenderIdx = g.nextActive(g.AttackerIdx) + g.startNewBout() +} + +// finishBoutAfterTake завершает раунд после того, как защищающийся +// забрал стол: атакующим становится следующий по кругу от него. +func (g *DurakGameState) finishBoutAfterTake(oldDefenderIdx int) { + g.markOutPlayers() + if g.checkGameOver() { + return + } + g.AttackerIdx = g.nextActive(oldDefenderIdx) + g.DefenderIdx = g.nextActive(g.AttackerIdx) + g.startNewBout() +} + +// markOutPlayers отмечает как выбывших (в безопасности) игроков, +// у которых закончились и карты на руке, и колода для добора. +func (g *DurakGameState) markOutPlayers() { + if len(g.Talon) > 0 { + return + } + for _, p := range g.Players { + if len(p.Hand) == 0 { + p.Out = true + } + } +} + +// checkGameOver проверяет, не осталось ли активных (не вышедших) +// игроков меньше двух — тогда партия завершена. +func (g *DurakGameState) checkGameOver() bool { + active := make([]int, 0, len(g.Players)) + for i, p := range g.Players { + if !p.Out { + active = append(active, i) + } + } + switch len(active) { + case 0: + g.Result = &DurakResult{NoLoser: true} + g.Phase = DurakPhaseGameOver + return true + case 1: + g.Result = &DurakResult{Loser: active[0]} + g.Phase = DurakPhaseGameOver + return true + default: + return false + } +} diff --git a/durak_game_test.go b/durak_game_test.go new file mode 100644 index 0000000..988919e --- /dev/null +++ b/durak_game_test.go @@ -0,0 +1,427 @@ +package main + +import "testing" + +func dc(rank Rank, suit Suit) Card { return Card{Rank: rank, Suit: suit} } + +func TestNewDurakGameDealsSixEach(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + for i, p := range g.Players { + if len(p.Hand) != 6 { + t.Errorf("игрок %d: ожидалось 6 карт, получено %d", i, len(p.Hand)) + } + } + // 36 - 18 в руках - 1 козырь на виду = 17 в талоне (козырь ещё + // формально считается в талоне, так как он его последняя карта) + if len(g.Talon) != 36-18 { + t.Fatalf("ожидалось %d карт в талоне, получено %d", 36-18, len(g.Talon)) + } + if g.Talon[len(g.Talon)-1] != g.TrumpCard { + t.Fatalf("последняя карта талона должна быть козырной картой") + } +} + +func TestNewDurakGameSixPlayersUnevenDeal(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C", "D", "E", "F"}, -1) + total := 0 + for _, p := range g.Players { + total += len(p.Hand) + } + // 35 карт доступно для раздачи (36 минус зарезервированный + // козырь), на 6 игроков по 6 не поделится ровно + if total != 35 { + t.Fatalf("ожидалось 35 розданных карт при 6 игроках, получено %d", total) + } + if len(g.Talon) != 1 { + t.Fatalf("в талоне должна была остаться только козырная карта, получено %d карт", len(g.Talon)) + } +} + +func TestInitialAttackerHasLowestTrump(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + + minTrumpRank := func(hand []Card) (int, bool) { + best := 99 + found := false + for _, c := range hand { + if c.Suit == g.TrumpSuit && durakRankOrder[c.Rank] < best { + best = durakRankOrder[c.Rank] + found = true + } + } + return best, found + } + + attackerMin, attackerHasTrump := minTrumpRank(g.Players[g.AttackerIdx].Hand) + if !attackerHasTrump { + return // допускаем случай, когда ни у кого нет козыря + } + for i, p := range g.Players { + if i == g.AttackerIdx { + continue + } + if otherMin, ok := minTrumpRank(p.Hand); ok && otherMin < attackerMin { + t.Errorf("у игрока %d козырь младше (%d), чем у выбранного атакующего (%d)", i, otherMin, attackerMin) + } + } +} + +// setupBout — тестовый хелпер: вручную задаёт роли, руки и козырь, +// затем через startNewBout() корректно инициализирует attackCap, +// ThrowOrder и Phase (вместо ручного дублирования этой логики в +// каждом тесте). +func setupBout(g *DurakGameState, attackerIdx, defenderIdx int, trump Suit, hands map[int][]Card) { + g.AttackerIdx = attackerIdx + g.DefenderIdx = defenderIdx + g.TrumpSuit = trump + for idx, hand := range hands { + g.Players[idx].Hand = hand + } + g.startNewBout() +} + +func TestThrowInAndDefendEntersThrowInPhase(t *testing.T) { + // 3 игрока и запас карт у защищающегося (cap=3), чтобы после + // первой защиты лимит атаки ещё не был исчерпан и раунд ушёл в + // фазу подкидывания, а не сразу в отбой + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs), dc(Nine, Hearts)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds), dc(Jack, Diamonds)}, + 2: {dc(Eight, Diamonds)}, + }) + + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка подкидывания: %v", err) + } + if g.Phase != DurakPhaseDefend { + t.Fatalf("ожидалась фаза защиты") + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка защиты: %v", err) + } + if g.Phase != DurakPhaseThrowIn { + t.Fatalf("после успешной защиты (лимит атаки ещё не исчерпан) ожидалась фаза подкидывания, получено %v", g.Phase) + } + if len(g.Table) != 1 || g.Table[0].Defend == nil { + t.Fatalf("карта на столе должна быть отбита") + } + // первый в очереди подкидывающих — атакующий раунда (игрок 0) + if g.CurrentThrower() != 0 { + t.Errorf("ожидался первый подкидывающий 0 (атакующий), получено %d", g.CurrentThrower()) + } +} + +func TestOtherPlayerCanThrowIn(t *testing.T) { + // ключевая проверка полноценных правил: подкинуть карту может не + // только атакующий, но и третий игрок (не защищающийся) + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds), dc(Jack, Diamonds)}, + 2: {dc(Six, Hearts)}, // тот же ранг, что уже на столе + }) + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != DurakPhaseThrowIn { + t.Fatalf("ожидалась фаза подкидывания") + } + + // игрок 0 (атакующий) пасует — очередь переходит к игроку 2 + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка паса: %v", err) + } + if g.CurrentThrower() != 2 { + t.Fatalf("ожидался подкидывающий 2, получено %d", g.CurrentThrower()) + } + + // игрок 2 (не атакующий и не защищающийся) подкидывает шестёрку червей + if err := g.ThrowIn(dc(Six, Hearts)); err != nil { + t.Fatalf("игрок 2 должен был иметь возможность подкинуть карту: %v", err) + } + if g.Phase != DurakPhaseDefend { + t.Fatalf("ожидалась фаза защиты после подкидывания игроком 2") + } + if len(g.Table) != 2 { + t.Fatalf("ожидалось 2 карты на столе, получено %d", len(g.Table)) + } +} + +func TestDefendRejectsWeakerCard(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Eight, Clubs)}, + 1: {dc(Six, Clubs)}, // младше по той же масти + }) + if err := g.ThrowIn(dc(Eight, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Six, Clubs)); err != ErrDurakInvalidDefend { + t.Errorf("ожидалась ошибка ErrDurakInvalidDefend, получено: %v", err) + } +} + +func TestTrumpBeatsNonTrump(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Ace, Clubs)}, + 1: {dc(Six, Spades)}, // младший козырь бьёт туза не в масть + }) + if err := g.ThrowIn(dc(Ace, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Six, Spades)); err != nil { + t.Errorf("младший козырь должен побить некозырного туза: %v", err) + } +} + +func TestNonTrumpCannotBeatTrump(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Spades)}, // козырная атака + 1: {dc(Ace, Clubs)}, + }) + if err := g.ThrowIn(dc(Six, Spades)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Ace, Clubs)); err != ErrDurakInvalidDefend { + t.Errorf("некозырная карта не должна бить козырь, получено: %v", err) + } +} + +func TestThrowInRejectsMismatchedRank(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs), dc(Nine, Hearts)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds)}, // 2 карты -> cap=2, не исчерпан после 1 карты + }) + + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != DurakPhaseThrowIn { + t.Fatalf("ожидалась фаза подкидывания, получено %v", g.Phase) + } + // девятка не совпадает по рангу ни с чем на столе (там только 6 и 7) + if err := g.ThrowIn(dc(Nine, Hearts)); err != ErrDurakInvalidThrowIn { + t.Errorf("ожидалась ошибка ErrDurakInvalidThrowIn, получено: %v", err) + } +} + +func TestPassingResolvesBito(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds), dc(Jack, Diamonds)}, + 2: {dc(Eight, Diamonds)}, // не подходит по рангу для подкидывания + }) + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != DurakPhaseThrowIn { + t.Fatalf("ожидалась фаза подкидывания") + } + + // оба потенциальных подкидывающих (0 и 2) пасуют по очереди + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка первого паса: %v", err) + } + if g.Phase == DurakPhaseGameOver { + t.Fatalf("партия не должна была завершиться") + } + if len(g.Table) == 0 { + t.Fatalf("после первого паса стол ещё не должен был очиститься (ждём второй пас)") + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка второго паса: %v", err) + } + if len(g.Table) != 0 { + t.Errorf("после второго паса стол должен был очиститься (бито)") + } +} + +func TestAutoResolvesWhenNoOneElseCanThrow(t *testing.T) { + // 2 игрока: после защиты подкидывать может только сам атакующий; + // если у него нет карт подходящего ранга — единственный пас в + // ThrowOrder сразу должен разрешить раунд бито + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs), dc(Nine, Hearts)}, // девятка не подходит по рангу + 1: {dc(Seven, Clubs), dc(Ten, Diamonds)}, + }) + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != DurakPhaseThrowIn { + t.Fatalf("ожидалась фаза подкидывания") + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка паса: %v", err) + } + if len(g.Table) != 0 { + t.Errorf("после единственного паса (2 игрока) стол должен был очиститься") + } +} + +func TestBitoRotatesRolesAndRefills(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds), dc(Jack, Diamonds)}, + 2: {dc(Eight, Diamonds)}, + }) + g.Talon = []Card{dc(Two, Hearts), dc(Three, Hearts)} // карты-заглушки, важен только факт добора + + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase == DurakPhaseGameOver { + t.Fatalf("партия не должна была завершиться в этом сценарии, результат: %+v", g.Result) + } + if len(g.Table) != 0 { + t.Errorf("стол должен был очиститься после бито") + } + // атакующим должен стать следующий после старого защищающегося (1) -> 2 + if g.AttackerIdx != 2 { + t.Errorf("ожидался новый атакующий 2, получено %d", g.AttackerIdx) + } + if g.DefenderIdx != 0 { + t.Errorf("ожидался новый защищающийся 0, получено %d", g.DefenderIdx) + } + if len(g.Talon) != 0 { + t.Errorf("талон должен был опустеть, осталось: %v", g.Talon) + } +} + +func TestTakeGivesTableCardsToDefenderAndRotates(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Eight, Clubs), dc(Ten, Diamonds)}, // запасная карта, чтобы не выбыть при пустом талоне + 1: {dc(Six, Hearts)}, // не бьёт и не козырь + 2: {dc(Nine, Diamonds)}, + }) + g.Talon = nil + + if err := g.ThrowIn(dc(Eight, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Take(); err != nil { + t.Fatalf("неожиданная ошибка взятия: %v", err) + } + if g.Phase == DurakPhaseGameOver { + t.Fatalf("партия не должна была завершиться в этом сценарии, результат: %+v", g.Result) + } + wantHand := []Card{dc(Six, Hearts), dc(Eight, Clubs)} + if len(g.Players[1].Hand) != len(wantHand) { + t.Fatalf("защищающийся должен был получить карту со стола вдобавок к своей, рука: %v", g.Players[1].Hand) + } + for _, c := range wantHand { + if !hasCard(g.Players[1].Hand, c) { + t.Errorf("в руке защищающегося должна была быть карта %v, рука: %v", c, g.Players[1].Hand) + } + } + if len(g.Table) != 0 { + t.Errorf("стол должен был очиститься после взятия") + } + // новым атакующим должен стать следующий после защищающегося (1) -> 2 + if g.AttackerIdx != 2 { + t.Errorf("ожидался новый атакующий 2, получено %d", g.AttackerIdx) + } + if g.DefenderIdx != 0 { + t.Errorf("ожидался новый защищающийся 0, получено %d", g.DefenderIdx) + } +} + +func TestGameOverWhenOnePlayerLeftWithCards(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs)}, + 1: {dc(Seven, Clubs), dc(Ten, Diamonds)}, // одна карта останется после защиты + }) + g.Talon = nil + + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != DurakPhaseGameOver { + t.Fatalf("игрок 0 избавился от последней карты при пустом талоне — партия должна была завершиться") + } + if g.Result == nil || g.Result.NoLoser { + t.Fatalf("ожидался единственный проигравший (игрок 1), получено: %+v", g.Result) + } + if g.Result.Loser != 1 { + t.Errorf("ожидался проигравший 1 (у него осталась карта), получено %d", g.Result.Loser) + } +} + +func TestAttackCapLimitsThrowIn(t *testing.T) { + g := NewDurakGame([]string{"A", "B"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs), dc(Six, Hearts), dc(Six, Diamonds)}, + 1: {dc(Seven, Clubs)}, // всего 1 карта -> cap = 1 + }) + if g.attackCap != 1 { + t.Fatalf("ожидался лимит атаки 1, получено %d", g.attackCap) + } + + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + // лимит атаки (1) уже достигнут -> раунд должен был автоматически + // разрешиться бито, не дожидаясь паса + if len(g.Table) != 0 { + t.Errorf("при исчерпанном лимите атаки стол должен был очиститься сразу после защиты") + } +} + +func TestCapReachedResolvesBitoWithoutWaitingForPass(t *testing.T) { + g := NewDurakGame([]string{"A", "B", "C"}, -1) + setupBout(g, 0, 1, Spades, map[int][]Card{ + 0: {dc(Six, Clubs)}, + 1: {dc(Seven, Clubs)}, // 1 карта -> cap=1 + 2: {dc(Six, Hearts)}, + }) + if g.attackCap != 1 { + t.Fatalf("ожидался лимит атаки 1, получено %d", g.attackCap) + } + if err := g.ThrowIn(dc(Six, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Defend(dc(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase == DurakPhaseThrowIn { + t.Fatalf("при исчерпанном лимите атаки фаза подкидывания не должна была наступить") + } +} diff --git a/durak_tui.go b/durak_tui.go new file mode 100644 index 0000000..7f1bdfd --- /dev/null +++ b/durak_tui.go @@ -0,0 +1,410 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// DurakModel — TUI поверх DurakGameState. Действующее лицо всегда +// одно: атакующий (обязательный первый ход раунда), защищающийся +// (отбить/забрать) либо очередной подкидывающий (подкинуть/пасовать +// в фазе DurakPhaseThrowIn) — определяется через currentActor(). +type DurakModel struct { + game *DurakGameState + humanIdx int + bot DurakBot + + cursor int + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewDurakModel создаёт новую партию Дурака на numPlayers игроков +// (2-6, включая человека) со случайными именами ботов. +func NewDurakModel(numPlayers int) DurakModel { + if numPlayers < 2 { + numPlayers = 2 + } + if numPlayers > 6 { + numPlayers = 6 + } + botNames := newDurakBotNames(numPlayers - 1) + names := append([]string{"Вы"}, botNames...) + + return DurakModel{ + game: NewDurakGame(names, 0), + humanIdx: 0, + bot: DurakBot{}, + } +} + +func (m DurakModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +// currentActor возвращает индекс игрока, который должен действовать +// прямо сейчас: атакующий в фазе первого хода, защищающийся в фазе +// защиты, очередной подкидывающий в фазе подкидывания. +func (m DurakModel) currentActor() int { + switch m.game.Phase { + case DurakPhaseAttack: + return m.game.AttackerIdx + case DurakPhaseDefend: + return m.game.DefenderIdx + case DurakPhaseThrowIn: + return m.game.CurrentThrower() + } + return -1 +} + +func (m DurakModel) maybeScheduleBot() tea.Cmd { + if m.game.Phase == DurakPhaseGameOver { + return nil + } + if m.currentActor() == m.humanIdx { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *DurakModel) resetSelection() { + hand := m.currentHand() + if len(hand) == 0 { + m.cursor = 0 + } else if m.cursor >= len(hand) { + m.cursor = len(hand) - 1 + } +} + +func (m DurakModel) currentHand() []Card { + idx := m.currentActor() + if idx < 0 { + return nil + } + return m.game.Players[idx].Hand +} + +func (m *DurakModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *DurakModel) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m DurakModel) 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 DurakModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Phase == DurakPhaseGameOver { + return m, nil + } + actor := m.currentActor() + if actor == m.humanIdx { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game); err != nil { + m.setError(fmt.Errorf("бот %s: %w", m.game.Players[actor].Name, err)) + return m, nil + } + m.setInfo("%s сходил(а).", m.game.Players[actor].Name) + if m.game.Phase != DurakPhaseGameOver && m.currentActor() == m.humanIdx { + m.resetSelection() + } + return m, m.maybeScheduleBot() +} + +func (m DurakModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + if m.game.Phase == DurakPhaseGameOver { + switch key { + case "n": + numPlayers := len(m.game.Players) + m = NewDurakModel(numPlayers) + return m, m.maybeScheduleBot() + case "q": + m.backToMenu = true + return m, nil + } + return m, nil + } + + if key == "q" { + m.backToMenu = true + return m, nil + } + + if m.currentActor() != m.humanIdx { + // сейчас действует бот — игнорируем остальной ввод + return m, nil + } + + switch m.game.Phase { + case DurakPhaseAttack: + return m.handleAttackKey(key) + case DurakPhaseDefend: + return m.handleDefendKey(key) + case DurakPhaseThrowIn: + return m.handleThrowInKey(key) + } + return m, nil +} + +func (m DurakModel) handleAttackKey(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.ThrowIn(card); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы подкинули %s.", card) + if m.game.Phase != DurakPhaseGameOver { + m.resetSelection() + } + return m, m.maybeScheduleBot() + } + return m, nil +} + +func (m DurakModel) handleThrowInKey(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.ThrowIn(card); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы подкинули %s.", card) + if m.game.Phase != DurakPhaseGameOver { + m.resetSelection() + } + return m, m.maybeScheduleBot() + case "p": + if err := m.game.Pass(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы пасовали.") + if m.game.Phase != DurakPhaseGameOver { + m.resetSelection() + } + return m, m.maybeScheduleBot() + } + return m, nil +} + +func (m DurakModel) handleDefendKey(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.Defend(card); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы отбились картой %s.", card) + if m.game.Phase != DurakPhaseGameOver { + m.resetSelection() + } + return m, m.maybeScheduleBot() + case "t": + if err := m.game.Take(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы забрали стол себе в руку.") + if m.game.Phase != DurakPhaseGameOver { + m.resetSelection() + } + return m, m.maybeScheduleBot() + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +func (m DurakModel) renderHand() string { + hand := m.currentHand() + if len(hand) == 0 { + return dimStyle.Render("(нет карт)") + } + 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 DurakModel) renderTable() string { + if len(m.game.Table) == 0 { + return dimStyle.Render("(стол пуст)") + } + parts := make([]string, len(m.game.Table)) + for i, pair := range m.game.Table { + if pair.Defend != nil { + parts[i] = fmt.Sprintf("%s/%s", renderCard(pair.Attack), renderCard(*pair.Defend)) + } else { + parts[i] = fmt.Sprintf("%s/__", renderCard(pair.Attack)) + } + } + return strings.Join(parts, " ") +} + +func (m DurakModel) renderPlayers() string { + var b strings.Builder + for i, p := range m.game.Players { + marker := " " + switch { + case m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower(): + marker = "T " + case i == m.game.AttackerIdx && m.game.Phase != DurakPhaseGameOver: + marker = "A " + case i == m.game.DefenderIdx && m.game.Phase != DurakPhaseGameOver: + marker = "D " + } + name := p.Name + if i == m.humanIdx { + name += " (вы)" + } + status := "" + if p.Out { + status = " [отбился]" + } + line := fmt.Sprintf("%s%-20s карт: %-2d%s", marker, name, len(p.Hand), status) + if i == m.game.AttackerIdx || i == m.game.DefenderIdx || (m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower()) { + line = turnStyle.Render(line) + } + fmt.Fprintln(&b, line) + } + return strings.TrimRight(b.String(), "\n") +} + +func (m DurakModel) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== ДУРАК ===")) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderPlayers()) + fmt.Fprintln(&b) + + trumpStr := renderCard(m.game.TrumpCard) + fmt.Fprintf(&b, "Козырь: %s В колоде: %d карт\n\n", trumpStr, len(m.game.Talon)) + + fmt.Fprintln(&b, "Стол (атака/защита):") + fmt.Fprintln(&b, m.renderTable()) + fmt.Fprintln(&b) + + if m.game.Phase == DurakPhaseGameOver { + fmt.Fprintln(&b, m.renderResult()) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + return b.String() + } + + if m.currentActor() == m.humanIdx { + fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, m.renderHand()) + fmt.Fprintln(&b) + + switch m.game.Phase { + case DurakPhaseAttack: + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть карту (обязательный первый ход) [q] в меню") + case DurakPhaseDefend: + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отбить карту [t] забрать стол [q] в меню") + case DurakPhaseThrowIn: + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть ещё карту [p] пасовать [q] в меню") + } + } else { + fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.currentActor()].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 DurakModel) renderResult() string { + res := m.game.Result + if res.Stalemate { + return errorStyle.Render("Партия зашла в тупик (карты слишком долго не выходили из игры) и объявлена ничьей.") + } + if res.NoLoser { + return winStyle.Render("Ничья! Все избавились от карт одновременно — дурака нет.") + } + loserName := m.game.Players[res.Loser].Name + if res.Loser == m.humanIdx { + return errorStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName)) + } + return winStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName)) +} diff --git a/durak_tui_test.go b/durak_tui_test.go new file mode 100644 index 0000000..3a68cfd --- /dev/null +++ b/durak_tui_test.go @@ -0,0 +1,241 @@ +package main + +import "testing" + +func TestDurakModelDealsCorrectPlayerCount(t *testing.T) { + for n := 2; n <= 6; n++ { + m := NewDurakModel(n) + if len(m.game.Players) != n { + t.Errorf("ожидалось %d игроков, получено %d", n, len(m.game.Players)) + } + } +} + +func TestDurakModelClampsPlayerCount(t *testing.T) { + m := NewDurakModel(1) + if len(m.game.Players) != 2 { + t.Errorf("ожидалось ограничение снизу до 2 игроков, получено %d", len(m.game.Players)) + } + m = NewDurakModel(10) + if len(m.game.Players) != 6 { + t.Errorf("ожидалось ограничение сверху до 6 игроков, получено %d", len(m.game.Players)) + } +} + +func TestDurakModelThrowInViaKeys(t *testing.T) { + m := NewDurakModel(2) + setupBout(m.game, m.humanIdx, 1-m.humanIdx, m.game.TrumpSuit, map[int][]Card{ + m.humanIdx: {c(Six, Clubs)}, + }) + m.cursor = 0 + + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + if m.isError { + t.Fatalf("неожиданная ошибка подкидывания: %s", m.message) + } + if m.game.Phase != DurakPhaseDefend { + t.Fatalf("ожидалась фаза защиты после подкидывания") + } + if len(m.game.Table) != 1 { + t.Fatalf("ожидалась 1 карта на столе") + } +} + +func TestDurakModelDefendViaKeys(t *testing.T) { + m := NewDurakModel(2) + setupBout(m.game, 1-m.humanIdx, m.humanIdx, Spades, map[int][]Card{ + 1 - m.humanIdx: {c(Six, Clubs)}, + m.humanIdx: {c(Seven, Clubs), c(Ten, Diamonds)}, // запасная карта, чтобы cap>1 + }) + if err := m.game.ThrowIn(c(Six, Clubs)); err != nil { + t.Fatalf("не удалось подготовить стол: %v", err) + } + m.cursor = 0 + + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + if m.isError { + t.Fatalf("неожиданная ошибка защиты: %s", m.message) + } + if len(m.game.Table) == 0 || m.game.Table[0].Defend == nil { + t.Fatalf("карта на столе должна быть отбита") + } +} + +func TestDurakModelTakeViaKeys(t *testing.T) { + m := NewDurakModel(2) + setupBout(m.game, 1-m.humanIdx, m.humanIdx, Spades, map[int][]Card{ + 1 - m.humanIdx: {c(Eight, Clubs)}, + m.humanIdx: {c(Six, Hearts)}, // не бьёт + }) + if err := m.game.ThrowIn(c(Eight, Clubs)); err != nil { + t.Fatalf("не удалось подготовить стол: %v", err) + } + + next, _ := m.Update(key("t")) + m = next.(DurakModel) + if m.isError { + t.Fatalf("неожиданная ошибка взятия: %s", m.message) + } + if !hasCard(m.game.Players[m.humanIdx].Hand, c(Eight, Clubs)) { + t.Fatalf("после взятия карта со стола должна была оказаться в руке") + } +} + +func TestDurakModelPassViaKeys(t *testing.T) { + m := NewDurakModel(2) + // у человека (игрока 0) достаточно карт на руке защищающегося, + // чтобы после защиты лимит атаки не был исчерпан и наступила + // фаза подкидывания, где единственный потенциальный подкидывающий + // (при 2 игроках) — сам атакующий, то есть человек + setupBout(m.game, m.humanIdx, 1-m.humanIdx, Spades, map[int][]Card{ + m.humanIdx: {c(Six, Clubs), c(Nine, Hearts)}, + 1 - m.humanIdx: {c(Seven, Clubs), c(Ten, Diamonds)}, + }) + if err := m.game.ThrowIn(c(Six, Clubs)); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if err := m.game.Defend(c(Seven, Clubs)); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if m.game.Phase != DurakPhaseThrowIn { + t.Fatalf("ожидалась фаза подкидывания, получено %v", m.game.Phase) + } + + next, _ := m.Update(key("p")) + m = next.(DurakModel) + if m.isError { + t.Fatalf("неожиданная ошибка паса: %s", m.message) + } + if len(m.game.Table) != 0 { + t.Fatalf("стол должен был очиститься после единственного паса (2 игрока)") + } +} + +func TestDurakModelOtherPlayerThrowInViaKeys(t *testing.T) { + // человек — третий игрок (не атакующий и не защищающийся), + // проверяем, что именно ему через TUI дают возможность подкинуть + m := NewDurakModel(3) + humanIdx := m.humanIdx + var otherIdx int + for i := range m.game.Players { + if i != humanIdx { + otherIdx = i + break + } + } + var thirdIdx int + for i := range m.game.Players { + if i != humanIdx && i != otherIdx { + thirdIdx = i + break + } + } + setupBout(m.game, otherIdx, thirdIdx, Spades, map[int][]Card{ + otherIdx: {c(Six, Clubs)}, + thirdIdx: {c(Seven, Clubs), c(Ten, Diamonds), c(Jack, Diamonds)}, + humanIdx: {c(Six, Hearts)}, + }) + if err := m.game.ThrowIn(c(Six, Clubs)); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if err := m.game.Defend(c(Seven, Clubs)); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if m.game.CurrentThrower() != otherIdx { + t.Fatalf("первым в очереди подкидывающих должен быть атакующий раунда") + } + + // атакующий (бот) пасует программно, чтобы дошла очередь до человека + if err := m.game.Pass(); err != nil { + t.Fatalf("ошибка подготовки паса: %v", err) + } + if m.game.CurrentThrower() != humanIdx { + t.Fatalf("ожидалось, что очередь дошла до человека (%d), получено %d", humanIdx, m.game.CurrentThrower()) + } + + m.cursor = 0 + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + if m.isError { + t.Fatalf("неожиданная ошибка подкидывания человеком: %s", m.message) + } + if len(m.game.Table) != 2 { + t.Fatalf("ожидалось 2 карты на столе после подкидывания человеком") + } +} + +func TestDurakModelQReturnsToMenu(t *testing.T) { + m := NewDurakModel(2) + next, _ := m.Update(key("q")) + m = next.(DurakModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +// TestDurakModelFullAutoPlaythrough прогоняет TUI-модель Дурака от +// начала до конца, где "человек" отыгрывает свои ходы через +// реальные нажатия клавиш (используя ту же логику, что и DurakBot), +// а не напрямую через DurakGameState — проверяем весь путь через +// Update целиком для нескольких вариантов числа игроков. +func TestDurakModelFullAutoPlaythrough(t *testing.T) { + for n := 2; n <= 6; n++ { + m := NewDurakModel(n) + const maxSteps = 10000 + steps := 0 + for m.game.Phase != DurakPhaseGameOver && steps < maxSteps { + steps++ + if m.currentActor() != m.humanIdx { + next, _ := m.Update(botMoveMsg{}) + m = next.(DurakModel) + continue + } + + selectCard := func(card Card) { + for i, hc := range m.currentHand() { + if hc == card { + m.cursor = i + break + } + } + } + + switch m.game.Phase { + case DurakPhaseAttack: + card, _ := m.bot.DecideAttack(m.game) + selectCard(card) + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + case DurakPhaseDefend: + if card, ok := m.bot.DecideDefend(m.game); ok { + selectCard(card) + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + } else { + next, _ := m.Update(key("t")) + m = next.(DurakModel) + } + case DurakPhaseThrowIn: + if card, ok := m.bot.DecideThrowOrPass(m.game); ok { + selectCard(card) + next, _ := m.Update(key("enter")) + m = next.(DurakModel) + } else { + next, _ := m.Update(key("p")) + m = next.(DurakModel) + } + } + } + if steps >= maxSteps { + t.Fatalf("игроков %d: партия через TUI не завершилась за %d шагов", n, steps) + } + if m.game.Result == nil { + t.Fatalf("игроков %d: партия завершилась без результата", n) + } + } +} diff --git a/game.go b/game.go new file mode 100644 index 0000000..2da239c --- /dev/null +++ b/game.go @@ -0,0 +1,497 @@ +package main + +import ( + "errors" + "fmt" +) + +// Phase — фаза текущего хода игрока. +type Phase int + +const ( + // PhaseDraw — игрок должен взять карту (из колоды или сброса) + // либо объявить Тонк, если это позволяет вес его руки. + PhaseDraw Phase = iota + // PhaseAction — игрок уже взял карту; может выкладывать + // комбинации, подкладывать карты к столу и в конце обязан + // сбросить одну карту. + PhaseAction + // PhaseGameOver — раунд завершён. + PhaseGameOver +) + +// MeldKind различает тип выложенной на стол комбинации. +type MeldKind int + +const ( + KindSet MeldKind = iota + KindSequence +) + +// TableMeld — комбинация, выложенная на стол. Владелец фиксируется +// тем, кто её впервые выложил, но подкладывать карты может любой +// игрок. +type TableMeld struct { + Owner int + Kind MeldKind + Cards []Card +} + +var ( + ErrWrongPhase = errors.New("недопустимое действие для текущей фазы хода") + ErrDeckExhausted = errors.New("колода и сброс исчерпаны — раунд закончен вничью") + ErrInvalidMeld = errors.New("карты не образуют валидную комбинацию") + ErrCardNotInHand = errors.New("карты нет на руке игрока") + ErrMeldNotFound = errors.New("комбинация на столе не найдена") + ErrCannotExtend = errors.New("карту нельзя подложить к этой комбинации") + ErrTonkNotAllowed = errors.New("вес руки превышает порог объявления Тонка") + ErrDiscardPileEmpty = errors.New("стопка сброса пуста") +) + +// DefaultTonkThreshold — по умолчанию Тонк можно объявить, если +// сумма очков карт на руке не превышает 5. Это игровое правило +// (не связано с деньгами) — вес руки, при котором вообще +// разрешено объявить Тонк. +const DefaultTonkThreshold = 5 + +// Денежные параметры по умолчанию: фиксированная ставка (анте) в +// банк раунда с каждого игрока, стартовый капитал и множитель +// банка при победе через объявление Тонка. +const ( + DefaultStartingCapital = 100 + DefaultAnte = 5 + DefaultTonkMultiplier = 2 +) + +// RoundResult — итог завершившегося раунда. +type RoundResult struct { + DeadRound bool // колода и сброс исчерпаны, банк возвращён игрокам + TonkCall bool // раунд завершён объявлением Тонка + Winner int // индекс победителя (не имеет смысла при DeadRound) + Pot int // размер банка на момент завершения раунда + BalanceDeltas []int // изменение капитала каждого игрока за этот раунд +} + +// GameState — полное состояние партии в Тонк. +type GameState struct { + Players []*PlayerState + Deck *Deck + DiscardPile []Card // последний элемент — верхняя карта + Table []TableMeld + CurrentPlayer int + Phase Phase + TonkThreshold int + Result *RoundResult // заполняется, когда Phase == PhaseGameOver + + // Денежная часть игры: фиксированная ставка с каждого игрока + // уходит в банк раунда; обычная победа забирает банк целиком, + // победа через Тонк — банк, умноженный на TonkMultiplier + // (недостающую часть доплачивают проигравшие сверх анте). + AnteAmount int + TonkMultiplier int + Pot int + + // StartingCapital сохраняется отдельно от текущего капитала + // игроков — это сумма, с которой садится за стол новый игрок, + // в том числе бот, заменяющий обанкротившегося (см. пакетный + // уровень, функцию checkBankruptcies в tui.go). + StartingCapital int + + // preRoundBalance — капитал каждого игрока непосредственно + // перед списанием анте в начале раунда. Используется только + // для вычисления BalanceDeltas в RoundResult (чтобы показать + // игроку итог всего раунда целиком, а не отдельно анте и + // отдельно выигрыш). + preRoundBalance []int + + // noProgressTurns считает подряд идущие завершённые ходы, за + // которые никто не выложил и не подложил ни одной карты в + // комбинацию на столе. Без такого предохранителя игроки без + // стратегии объявления Тонка теоретически могут застрять, + // бесконечно беря и сбрасывая одну и ту же лишнюю карту — + // такой раунд принудительно завершается вничью. + noProgressTurns int +} + +// maxNoProgressTurns — порог принудительного завершения "мёртвого" +// раунда, если долгое время не происходит никакого прогресса по +// комбинациям на столе. +const maxNoProgressTurns = 400 + +// NewGame создаёт новую партию со стандартными денежными +// параметрами (см. DefaultStartingCapital, DefaultAnte, +// DefaultTonkMultiplier). humanIdx — индекс живого игрока (может +// быть -1, если играют только боты, например для симуляций). +func NewGame(names []string, humanIdx int) *GameState { + return NewGameWithStakes(names, humanIdx, DefaultStartingCapital, DefaultAnte, DefaultTonkMultiplier) +} + +// NewGameWithStakes — то же самое, что NewGame, но с явно заданными +// денежными параметрами: стартовый капитал каждого игрока, размер +// фиксированной ставки (анте) в банк раунда и множитель банка при +// победе через Тонк. +func NewGameWithStakes(names []string, humanIdx, startingCapital, ante, tonkMultiplier int) *GameState { + isHuman := make([]bool, len(names)) + if humanIdx >= 0 && humanIdx < len(names) { + isHuman[humanIdx] = true + } + balances := make([]int, len(names)) + for i := range balances { + balances[i] = startingCapital + } + return newRound(names, isHuman, balances, DefaultTonkThreshold, startingCapital, ante, tonkMultiplier) +} + +// NextRound начинает новый раунд партии, сохраняя капитал игроков +// (и то, кто из них человек) из завершившегося раунда g. Ставка +// (анте) списывается заново из перенесённого капитала. Если перед +// вызовом NextRound в g.Players были подменены обанкротившиеся +// игроки (см. checkBankruptcies в tui.go), новый раунд стартует уже +// с обновлённым составом. +func (g *GameState) NextRound() *GameState { + names := make([]string, len(g.Players)) + isHuman := make([]bool, len(g.Players)) + balances := make([]int, len(g.Players)) + for i, p := range g.Players { + names[i] = p.Name + isHuman[i] = p.IsHuman + balances[i] = p.Balance + } + return newRound(names, isHuman, balances, g.TonkThreshold, g.StartingCapital, g.AnteAmount, g.TonkMultiplier) +} + +// newRound — общая внутренняя логика раздачи новой партии/раунда: +// тасует колоду, раздаёт по 5 карт, вскрывает первую карту сброса +// и собирает анте в банк. +func newRound(names []string, isHuman []bool, balances []int, tonkThreshold, startingCapital, ante, tonkMultiplier int) *GameState { + deck := NewDeck() + deck.Shuffle() + + players := make([]*PlayerState, len(names)) + for i, name := range names { + players[i] = &PlayerState{ + Name: name, + Hand: deck.DrawN(5), + IsHuman: isHuman[i], + Balance: balances[i], + } + } + + firstDiscard, _ := deck.Draw() + + g := &GameState{ + Players: players, + Deck: deck, + DiscardPile: []Card{firstDiscard}, + CurrentPlayer: 0, + Phase: PhaseDraw, + TonkThreshold: tonkThreshold, + AnteAmount: ante, + TonkMultiplier: tonkMultiplier, + StartingCapital: startingCapital, + } + g.collectAnte() + return g +} + +// collectAnte списывает фиксированную ставку с каждого игрока в +// банк раунда. Капитал может уйти в минус, если у игрока не +// хватает средств на анте — это сознательное упрощение (реальный +// долг между игроками здесь не моделируется). +func (g *GameState) collectAnte() { + g.preRoundBalance = make([]int, len(g.Players)) + for i, p := range g.Players { + g.preRoundBalance[i] = p.Balance + p.Balance -= g.AnteAmount + g.Pot += g.AnteAmount + } +} + +func (g *GameState) current() *PlayerState { + return g.Players[g.CurrentPlayer] +} + +// PeekDiscard возвращает верхнюю карту стопки сброса. +func (g *GameState) PeekDiscard() (Card, bool) { + if len(g.DiscardPile) == 0 { + return Card{}, false + } + return g.DiscardPile[len(g.DiscardPile)-1], true +} + +// reshuffleDiscardIntoDeck перекладывает стопку сброса (кроме +// верхней карты) обратно в колоду и тасует её — стандартный приём +// раммі-игр, когда колода добора заканчивается. +func (g *GameState) reshuffleDiscardIntoDeck() { + if len(g.DiscardPile) <= 1 { + return + } + top := g.DiscardPile[len(g.DiscardPile)-1] + rest := g.DiscardPile[:len(g.DiscardPile)-1] + g.Deck.cards = append(g.Deck.cards, rest...) + g.Deck.Shuffle() + g.DiscardPile = []Card{top} +} + +// DrawFromDeck — взять карту из колоды добора. Если колода пуста, +// сначала пытается переложить сброс в колоду; если и это +// невозможно, раунд завершается вничью. +func (g *GameState) DrawFromDeck() (Card, error) { + if g.Phase != PhaseDraw { + return Card{}, ErrWrongPhase + } + if g.Deck.Len() == 0 { + g.reshuffleDiscardIntoDeck() + } + card, ok := g.Deck.Draw() + if !ok { + g.endDeadRound() + return Card{}, ErrDeckExhausted + } + g.current().Hand = append(g.current().Hand, card) + g.Phase = PhaseAction + return card, nil +} + +// DrawFromDiscard — взять верхнюю карту из стопки сброса. +func (g *GameState) DrawFromDiscard() (Card, error) { + if g.Phase != PhaseDraw { + return Card{}, ErrWrongPhase + } + card, ok := g.PeekDiscard() + if !ok { + return Card{}, ErrDiscardPileEmpty + } + g.DiscardPile = g.DiscardPile[:len(g.DiscardPile)-1] + g.current().Hand = append(g.current().Hand, card) + g.Phase = PhaseAction + return card, nil +} + +// DeclareTonk — объявить Тонк. Разрешено только сразу после взятия +// карты (в фазе PhaseAction), до выкладывания комбинаций, и только +// если вес руки не превышает TonkThreshold. +func (g *GameState) DeclareTonk() (*RoundResult, error) { + if g.Phase != PhaseAction { + return nil, ErrWrongPhase + } + if !CanDeclareTonk(g.current().Hand, g.TonkThreshold) { + return nil, ErrTonkNotAllowed + } + return g.endByTonk(g.CurrentPlayer), nil +} + +// LayNewMeld выкладывает новую комбинацию на стол из карт руки +// текущего игрока. +func (g *GameState) LayNewMeld(cards []Card) error { + if g.Phase != PhaseAction { + return ErrWrongPhase + } + for _, c := range cards { + if !g.current().HasCard(c) { + return ErrCardNotInHand + } + } + var kind MeldKind + switch { + case IsSet(cards): + kind = KindSet + case IsSequence(cards): + kind = KindSequence + default: + return ErrInvalidMeld + } + // копируем ДО удаления из руки: если вызывающий код передал сам + // срез p.Hand (а не его копию), RemoveCards будет мутировать тот + // же массив в памяти, и cards станет невалидным + cardsCopy := append([]Card{}, cards...) + g.current().RemoveCards(cardsCopy) + g.Table = append(g.Table, TableMeld{ + Owner: g.CurrentPlayer, + Kind: kind, + Cards: cardsCopy, + }) + g.noProgressTurns = 0 + g.checkEmptyHandWin() + return nil +} + +// AddToMeld подкладывает одну карту руки текущего игрока к уже +// выложенной на столе комбинации (своей или чужой). +func (g *GameState) AddToMeld(meldIdx int, card Card) error { + if g.Phase != PhaseAction { + return ErrWrongPhase + } + if meldIdx < 0 || meldIdx >= len(g.Table) { + return ErrMeldNotFound + } + if !g.current().HasCard(card) { + return ErrCardNotInHand + } + meld := g.Table[meldIdx] + trial := append(append([]Card{}, meld.Cards...), card) + + valid := false + switch meld.Kind { + case KindSet: + valid = IsSet(trial) + case KindSequence: + valid = IsSequence(trial) + } + if !valid { + return ErrCannotExtend + } + + g.current().RemoveCard(card) + g.Table[meldIdx].Cards = trial + g.noProgressTurns = 0 + g.checkEmptyHandWin() + return nil +} + +// Discard сбрасывает карту и завершает ход текущего игрока. +func (g *GameState) Discard(card Card) error { + if g.Phase != PhaseAction { + return ErrWrongPhase + } + if g.Phase == PhaseGameOver { + return ErrWrongPhase + } + if !g.current().HasCard(card) { + return ErrCardNotInHand + } + g.current().RemoveCard(card) + g.DiscardPile = append(g.DiscardPile, card) + + if len(g.current().Hand) == 0 { + g.endByEmptyHand(g.CurrentPlayer) + return nil + } + + g.noProgressTurns++ + if g.noProgressTurns >= maxNoProgressTurns { + g.endDeadRound() + return nil + } + + g.advanceTurn() + return nil +} + +// checkEmptyHandWin проверяет, не остался ли текущий игрок без +// карт сразу после выкладывания/подкладывания комбинации — в этом +// случае раунд заканчивается его победой без необходимости сброса. +func (g *GameState) checkEmptyHandWin() { + if g.Phase == PhaseGameOver { + return + } + if len(g.current().Hand) == 0 { + g.endByEmptyHand(g.CurrentPlayer) + } +} + +func (g *GameState) advanceTurn() { + g.CurrentPlayer = (g.CurrentPlayer + 1) % len(g.Players) + g.Phase = PhaseDraw +} + +// endByEmptyHand завершает раунд победой игрока winner, полностью +// избавившегося от карт. Победитель забирает весь банк раунда +// (сумму ставок всех игроков); остальные при этом уже заплатили +// свою часть банка в виде анте в начале раунда. +func (g *GameState) endByEmptyHand(winner int) { + g.Players[winner].Balance += g.Pot + pot := g.Pot + g.Pot = 0 + + g.Result = &RoundResult{ + Winner: winner, + Pot: pot, + BalanceDeltas: g.balanceDeltasFromPreRound(), + } + g.Phase = PhaseGameOver +} + +// endByTonk завершает раунд объявлением Тонка игроком declarer. +// Объявивший забирает банк, умноженный на TonkMultiplier: помимо +// уже собранного банка, каждый из остальных игроков доплачивает +// поровну недостающую разницу (округление в меньшую сторону — +// остаток от деления забирает победитель, чтобы сумма выплат +// сходилась без потерь). +func (g *GameState) endByTonk(declarer int) *RoundResult { + pot := g.Pot + totalPayout := pot * g.TonkMultiplier + extra := totalPayout - pot + + losers := make([]int, 0, len(g.Players)-1) + for i := range g.Players { + if i != declarer { + losers = append(losers, i) + } + } + actualExtra := 0 + if len(losers) > 0 { + share := extra / len(losers) + remainder := extra % len(losers) + for idx, i := range losers { + deduction := share + if idx < remainder { + // остаток от целочисленного деления распределяем по + // первым проигравшим, чтобы сумма выплат сходилась + // с точностью до копейки, а не терялась в округлении + deduction++ + } + g.Players[i].Balance -= deduction + actualExtra += deduction + } + } + g.Players[declarer].Balance += pot + actualExtra + g.Pot = 0 + + g.Result = &RoundResult{ + TonkCall: true, + Winner: declarer, + Pot: pot, + BalanceDeltas: g.balanceDeltasFromPreRound(), + } + g.Phase = PhaseGameOver + return g.Result +} + +// endDeadRound завершает раунд без победителя — колода и сброс +// исчерпаны. Банк возвращается игрокам поровну (каждому — его +// анте назад), чтобы никто не терял деньги в раунде без вины. +func (g *GameState) endDeadRound() { + for _, p := range g.Players { + p.Balance += g.AnteAmount + } + pot := g.Pot + g.Pot = 0 + + g.Result = &RoundResult{ + DeadRound: true, + Pot: pot, + BalanceDeltas: g.balanceDeltasFromPreRound(), + } + g.Phase = PhaseGameOver +} + +// balanceDeltasFromPreRound считает итоговое изменение капитала +// каждого игрока за весь раунд целиком — от капитала до списания +// анте и до текущего момента после выплат. Это удобнее для +// отображения игроку, чем показывать анте и выигрыш раздельно. +func (g *GameState) balanceDeltasFromPreRound() []int { + deltas := make([]int, len(g.Players)) + for i, p := range g.Players { + deltas[i] = p.Balance - g.preRoundBalance[i] + } + return deltas +} + +// String — компактное текстовое представление состояния стола, +// полезно для отладки и логирования симуляций. +func (g *GameState) String() string { + top, _ := g.PeekDiscard() + return fmt.Sprintf("ход: %s | фаза: %d | сброс сверху: %s | колода: %d карт", + g.current().Name, g.Phase, top, g.Deck.Len()) +} diff --git a/game_test.go b/game_test.go new file mode 100644 index 0000000..e2e68a9 --- /dev/null +++ b/game_test.go @@ -0,0 +1,244 @@ +package main + +import "testing" + +func TestNewGameDealsCorrectly(t *testing.T) { + g := NewGame([]string{"A", "B", "C"}, -1) + if len(g.Players) != 3 { + t.Fatalf("ожидалось 3 игрока, получено %d", len(g.Players)) + } + for _, p := range g.Players { + if len(p.Hand) != 5 { + t.Errorf("у игрока %s ожидалось 5 карт, получено %d", p.Name, len(p.Hand)) + } + } + if _, ok := g.PeekDiscard(); !ok { + t.Error("ожидалась вскрытая карта в сбросе после раздачи") + } + // 3*5 карт игрокам + 1 в сброс = 16, в колоде должно остаться 36 + if g.Deck.Len() != 52-15-1 { + t.Errorf("неверное количество карт в колоде: %d", g.Deck.Len()) + } +} + +func TestDrawDiscardCycle(t *testing.T) { + g := NewGame([]string{"A", "B"}, -1) + handBefore := len(g.current().Hand) + + if _, err := g.DrawFromDeck(); err != nil { + t.Fatalf("неожиданная ошибка при взятии карты: %v", err) + } + if len(g.current().Hand) != handBefore+1 { + t.Fatalf("рука должна была вырасти на 1 карту") + } + if g.Phase != PhaseAction { + t.Fatalf("после взятия карты ожидалась фаза PhaseAction, получена %v", g.Phase) + } + + card := g.current().Hand[0] + if err := g.Discard(card); err != nil { + t.Fatalf("неожиданная ошибка при сбросе: %v", err) + } + if g.CurrentPlayer != 1 { + t.Fatalf("ход должен был перейти ко второму игроку, текущий: %d", g.CurrentPlayer) + } + if g.Phase != PhaseDraw { + t.Fatalf("после сброса ожидалась фаза PhaseDraw, получена %v", g.Phase) + } +} + +func TestWrongPhaseRejected(t *testing.T) { + g := NewGame([]string{"A", "B"}, -1) + // ещё не брали карту — сброс должен быть запрещён + if err := g.Discard(g.current().Hand[0]); err != ErrWrongPhase { + t.Errorf("ожидалась ошибка ErrWrongPhase, получено: %v", err) + } + // повторное взятие карты в фазе PhaseDraw -> PhaseAction, затем + // повторный DrawFromDeck должен быть отклонён + if _, err := g.DrawFromDeck(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if _, err := g.DrawFromDeck(); err != ErrWrongPhase { + t.Errorf("повторное взятие карты в фазе PhaseAction должно быть запрещено, получено: %v", err) + } +} + +func TestEndByEmptyHandTakesPot(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B", "C"}, -1, 100, 10, 2) + // банк уже собран в NewGameWithStakes: 3 игрока * 10 = 30 + if g.Pot != 30 { + t.Fatalf("ожидался банк 30 после сбора анте, получено %d", g.Pot) + } + for _, p := range g.Players { + if p.Balance != 90 { + t.Fatalf("ожидался капитал 90 после анте, получено %d у %s", p.Balance, p.Name) + } + } + + g.Players[0].Hand = []Card{c(Five, Hearts)} + g.CurrentPlayer = 0 + g.Phase = PhaseAction + + if err := g.Discard(c(Five, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка при сбросе: %v", err) + } + if g.Phase != PhaseGameOver { + t.Fatalf("раунд должен был завершиться победой игрока 0") + } + if g.Result.Winner != 0 { + t.Fatalf("ожидался победитель 0, получен %d", g.Result.Winner) + } + if g.Result.Pot != 30 { + t.Fatalf("ожидался банк раунда 30, получено %d", g.Result.Pot) + } + // победитель: было 90, получил банк 30 -> 120; итоговое изменение + // относительно капитала ДО анте (100): +20 + if g.Players[0].Balance != 120 { + t.Errorf("ожидался капитал победителя 120, получено %d", g.Players[0].Balance) + } + if g.Result.BalanceDeltas[0] != 20 { + t.Errorf("ожидалось изменение капитала +20 у победителя, получено %d", g.Result.BalanceDeltas[0]) + } + for i := 1; i < 3; i++ { + if g.Players[i].Balance != 90 { + t.Errorf("проигравший %d должен остаться с капиталом 90, получено %d", i, g.Players[i].Balance) + } + if g.Result.BalanceDeltas[i] != -10 { + t.Errorf("проигравший %d должен потерять анте (-10), получено %d", i, g.Result.BalanceDeltas[i]) + } + } +} + +func TestDeadRoundRefundsAnte(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) + g.endDeadRound() + if !g.Result.DeadRound { + t.Fatalf("ожидался DeadRound=true") + } + for i, p := range g.Players { + if p.Balance != 100 { + t.Errorf("капитал игрока %d должен был вернуться к 100, получено %d", i, p.Balance) + } + if g.Result.BalanceDeltas[i] != 0 { + t.Errorf("изменение капитала при мёртвом раунде должно быть 0, получено %d", g.Result.BalanceDeltas[i]) + } + } +} + +func TestDeclareTonk(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) + // банк = 20 (2 игрока * 10), капитал каждого сейчас 90 + g.Players[0].Hand = []Card{c(Ace, Hearts), c(Two, Clubs)} // вес 3 <= порога + g.CurrentPlayer = 0 + g.Phase = PhaseAction + + res, err := g.DeclareTonk() + if err != nil { + t.Fatalf("неожиданная ошибка объявления Тонка: %v", err) + } + if !res.TonkCall || res.Winner != 0 { + t.Fatalf("ожидался Тонк от игрока 0: %+v", res) + } + // банк ×2 = 40; победитель получает 40 (90+40=130, дельта +30 + // относительно стартовых 100); единственный проигравший + // доплачивает разницу (extra=20) сверх уже отданного анте: + // 90-20=70, дельта -30 относительно стартовых 100 + if g.Players[0].Balance != 130 { + t.Errorf("ожидался капитал победителя 130, получено %d", g.Players[0].Balance) + } + if g.Players[1].Balance != 70 { + t.Errorf("ожидался капитал проигравшего 70, получено %d", g.Players[1].Balance) + } + if res.BalanceDeltas[0] != 30 || res.BalanceDeltas[1] != -30 { + t.Errorf("неверный расчёт изменения капитала при Тонке: %v", res.BalanceDeltas) + } +} + +func TestDeclareTonkRejectedAboveThreshold(t *testing.T) { + g := NewGame([]string{"A", "B"}, -1) + g.Players[0].Hand = []Card{c(King, Hearts), c(Nine, Clubs)} // вес 19 + g.CurrentPlayer = 0 + g.Phase = PhaseAction + + if _, err := g.DeclareTonk(); err != ErrTonkNotAllowed { + t.Errorf("ожидалась ошибка ErrTonkNotAllowed, получено: %v", err) + } +} + +func TestLayNewMeldAndAddToMeld(t *testing.T) { + g := NewGame([]string{"A", "B"}, -1) + g.Players[0].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades), c(Two, Diamonds)} + g.CurrentPlayer = 0 + g.Phase = PhaseAction + + set := []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades)} + if err := g.LayNewMeld(set); err != nil { + t.Fatalf("неожиданная ошибка выкладки сета: %v", err) + } + if len(g.Table) != 1 { + t.Fatalf("ожидалась 1 комбинация на столе, получено %d", len(g.Table)) + } + if len(g.current().Hand) != 1 { + t.Fatalf("после выкладки в руке должна остаться 1 карта, получено %d", len(g.current().Hand)) + } + + // второй игрок подкладывает четвёртую семёрку + g.Players[1].Hand = []Card{c(Seven, Diamonds)} + g.CurrentPlayer = 1 + g.Phase = PhaseAction + if err := g.AddToMeld(0, c(Seven, Diamonds)); err != nil { + t.Fatalf("неожиданная ошибка подкладки: %v", err) + } + if len(g.Table[0].Cards) != 4 { + t.Errorf("ожидалось 4 карты в комбинации после подкладки, получено %d", len(g.Table[0].Cards)) + } +} + +// TestFullBotGames прогоняет много полных партий силами ботов всех +// трёх уровней в разных сочетаниях и проверяет, что игра всегда +// корректно завершается (без паники и без зависания) и что очки +// нулевой суммы (кроме мёртвых раундов). +func TestFullBotGames(t *testing.T) { + configs := [][]BotStrategy{ + {SimpleBot{}, SimpleBot{}, SimpleBot{}, SimpleBot{}}, + {MediumBot{}, MediumBot{}, MediumBot{}, MediumBot{}}, + {TonkBot{}, TonkBot{}, TonkBot{}, TonkBot{}}, + {SimpleBot{}, MediumBot{}, TonkBot{}, SimpleBot{}}, + } + + for cfgIdx, bots := range configs { + for game := 0; game < 20; game++ { + names := make([]string, len(bots)) + for i := range names { + names[i] = "Bot" + } + g := NewGame(names, -1) + + const maxTurns = 1000 + turn := 0 + for ; turn < maxTurns && g.Phase != PhaseGameOver; turn++ { + bot := bots[g.CurrentPlayer] + if err := bot.PlayTurn(g); err != nil { + t.Fatalf("конфиг %d, игра %d: ошибка на ходу бота %d: %v", + cfgIdx, game, g.CurrentPlayer, err) + } + } + + if turn >= maxTurns { + t.Fatalf("конфиг %d, игра %d: партия не завершилась за %d ходов (возможное зависание)", + cfgIdx, game, maxTurns) + } + if g.Result == nil { + t.Fatalf("конфиг %d, игра %d: партия завершилась без результата", cfgIdx, game) + } + sum := 0 + for _, d := range g.Result.BalanceDeltas { + sum += d + } + if sum != 0 { + t.Errorf("конфиг %d, игра %d: сумма изменений капитала должна быть 0, получено %d (%v)", + cfgIdx, game, sum, g.Result.BalanceDeltas) + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8132858 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module tonk + +go 1.22.2 + +replace golang.org/x/sys => github.com/golang/sys v0.27.0 + +replace golang.org/x/text => github.com/golang/text v0.3.8 + +replace golang.org/x/sync => github.com/golang/sync v0.9.0 + +require ( + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6f24765 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang/sync v0.9.0 h1:sCdfkewoLz90cqjEMqA7AE/TG2+FGhmaNo9ZnuYanw8= +github.com/golang/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +github.com/golang/sys v0.27.0 h1:N+9HlO9MKyu/VnWPA19fJW/mjjuvyp3MUQRQHQ5mFuI= +github.com/golang/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/golang/text v0.3.8 h1:KtGx+RPGLIfTAOmhhbcxU0utB7MPw2s68/aJvbjGZnk= +github.com/golang/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/klondike_bot_test.go b/klondike_bot_test.go new file mode 100644 index 0000000..5ff505f --- /dev/null +++ b/klondike_bot_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "math/rand" + "testing" +) + +// klondikeAutoStep пытается совершить один разумный ход: сначала +// проверяет, можно ли отправить видимую карту (из отбоя или любой +// колонны) на фундамент; если нет — пробует случайный допустимый ход +// между колоннами или из отбоя в колонну; если и это невозможно — +// добирает из колоды. Возвращает false, если вообще ничего сделать +// нельзя (колода и отбой пусты, и нет легальных ходов) — сигнал +// закончить прогон. +func klondikeAutoStep(g *KlondikeGameState, rnd *rand.Rand) bool { + for col := 0; col < 7; col++ { + if g.MoveTableauToFoundation(col) == nil { + return true + } + } + if g.MoveWasteToFoundation() == nil { + return true + } + + order := rnd.Perm(7) + for _, from := range order { + destOrder := rnd.Perm(7) + for _, to := range destOrder { + if from == to { + continue + } + if g.MoveTableauToTableau(from, to) == nil { + return true + } + } + } + for _, to := range rnd.Perm(7) { + if g.MoveWasteToTableau(to) == nil { + return true + } + } + + if g.DrawStock() == nil { + return true + } + return false +} + +func TestKlondikeAutoPlayManyGamesNoCorruption(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + + for game := 0; game < 200; game++ { + g := NewKlondikeGame() + + const maxSteps = 3000 + stalledStreak := 0 + for step := 0; step < maxSteps; step++ { + progressed := klondikeAutoStep(g, rnd) + if !progressed { + stalledStreak++ + if stalledStreak > 3 { + break + } + } else { + stalledStreak = 0 + } + if g.Won { + break + } + + seen := map[Card]bool{} + total := 0 + checkAdd := func(c Card) { + if seen[c] { + t.Fatalf("игра %d, шаг %d: карта %v встретилась дважды", game, step, c) + } + seen[c] = true + total++ + } + for col := 0; col < 7; col++ { + for _, kc := range g.Tableau[col] { + checkAdd(kc.Card) + } + } + for _, c := range g.Stock { + checkAdd(c) + } + for _, c := range g.Waste { + checkAdd(c) + } + for s, r := range g.Foundations { + for rank := 1; rank <= r; rank++ { + checkAdd(Card{Suit: Suit(s), Rank: Rank(rank)}) + } + } + if total != 52 { + t.Fatalf("игра %d, шаг %d: ожидалось 52 карты в игре, получено %d", game, step, total) + } + } + } +} diff --git a/klondike_game.go b/klondike_game.go new file mode 100644 index 0000000..5c1b23d --- /dev/null +++ b/klondike_game.go @@ -0,0 +1,273 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: реализован классический "Клондайк" +// (Косынка) с добором по одной карте за раз и неограниченным числом +// пересдач колоды из отбоя. Перемещать между колоннами можно только +// целиком собранную по правилам последовательность (по убыванию, +// чередуя цвет мастей) — нельзя "разрезать" такую цепочку и взять +// только её часть. Возврат карт с фундамента обратно в игру не +// реализован — это распространённое упрощение цифровых версий. + +// KlondikeCard — карта в колонне: помимо самой карты нужно знать, +// открыта она лицом вверх или ещё лежит рубашкой вверх. +type KlondikeCard struct { + Card Card + FaceUp bool +} + +var ( + ErrKlondikeEmptySource = errors.New("в исходной стопке нет карт") + ErrKlondikeNotMovable = errors.New("эта карта (или последовательность под ней) не может быть перемещена") + ErrKlondikeInvalidTableau = errors.New("эту карту нельзя положить в эту колонну") + ErrKlondikeInvalidFoundation = errors.New("эту карту нельзя положить на фундамент") + ErrKlondikeMultiToFoundation = errors.New("на фундамент можно положить только одну карту") + ErrKlondikeNoUndo = errors.New("нечего отменять") +) + +// KlondikeGameState — полное состояние партии пасьянса. +type KlondikeGameState struct { + Tableau [7][]KlondikeCard + Foundations [4]int // индекс — Suit; значение — старший ранг на фундаменте (0 = пусто) + Stock []Card // рубашкой вверх; Stock[len-1] — верхняя (берётся первой) + Waste []Card // отбой лицом вверх; Waste[len-1] — верхняя (видимая) + Moves int + Won bool + + history []klondikeSnapshot +} + +// NewKlondikeGame раздаёт новую партию: колонны по 1..7 карт (верхняя +// открыта), остаток — колода добора. +func NewKlondikeGame() *KlondikeGameState { + deck := NewDeck() + deck.Shuffle() + + g := &KlondikeGameState{} + for col := 0; col < 7; col++ { + for row := 0; row <= col; row++ { + card, _ := deck.Draw() + g.Tableau[col] = append(g.Tableau[col], KlondikeCard{Card: card, FaceUp: row == col}) + } + } + g.Stock = deck.cards + return g +} + +func isRedSuit(s Suit) bool { + return s == Hearts || s == Diamonds +} + +// movableRun возвращает длину наибольшей корректно собранной (по +// убыванию, с чередованием цвета) последовательности лицевых карт в +// конце (внизу) колонны col — именно столько карт можно перемещать +// вместе одним ходом. Если конец колонны не образует такой +// последовательности, движется только самая нижняя (видимая) карта. +func (g *KlondikeGameState) movableRun(col int) int { + cards := g.Tableau[col] + n := 0 + for i := len(cards) - 1; i >= 0; i-- { + if !cards[i].FaceUp { + break + } + if n == 0 { + n = 1 + continue + } + below := cards[i] + above := cards[i+1] + if int(below.Card.Rank) == int(above.Card.Rank)+1 && isRedSuit(below.Card.Suit) != isRedSuit(above.Card.Suit) { + n++ + } else { + break + } + } + return n +} + +// klondikeSnapshot — полная копия состояния для отмены хода. +type klondikeSnapshot struct { + tableau [7][]KlondikeCard + foundations [4]int + stock []Card + waste []Card + moves int +} + +func (g *KlondikeGameState) snapshot() klondikeSnapshot { + var s klondikeSnapshot + for i := range g.Tableau { + s.tableau[i] = append([]KlondikeCard{}, g.Tableau[i]...) + } + s.foundations = g.Foundations + s.stock = append([]Card{}, g.Stock...) + s.waste = append([]Card{}, g.Waste...) + s.moves = g.Moves + return s +} + +func (g *KlondikeGameState) pushHistory() { + g.history = append(g.history, g.snapshot()) + if len(g.history) > 200 { + g.history = g.history[1:] + } +} + +// Undo отменяет последний совершённый ход. +func (g *KlondikeGameState) Undo() error { + if len(g.history) == 0 { + return ErrKlondikeNoUndo + } + last := g.history[len(g.history)-1] + g.history = g.history[:len(g.history)-1] + g.Tableau = last.tableau + g.Foundations = last.foundations + g.Stock = last.stock + g.Waste = last.waste + g.Moves = last.moves + g.Won = false + return nil +} + +// DrawStock переносит верхнюю карту колоды добора в отбой лицом +// вверх; если колода добора пуста — перекладывает весь отбой +// обратно в колоду (в обратном порядке) для новой пересдачи. +func (g *KlondikeGameState) DrawStock() error { + if len(g.Stock) == 0 && len(g.Waste) == 0 { + return ErrKlondikeEmptySource + } + g.pushHistory() + if len(g.Stock) == 0 { + for i, j := 0, len(g.Waste)-1; i < j; i, j = i+1, j-1 { + g.Waste[i], g.Waste[j] = g.Waste[j], g.Waste[i] + } + g.Stock = g.Waste + g.Waste = nil + return nil + } + card := g.Stock[len(g.Stock)-1] + g.Stock = g.Stock[:len(g.Stock)-1] + g.Waste = append(g.Waste, card) + g.Moves++ + return nil +} + +// canPlaceOnTableau проверяет, можно ли положить card на верхнюю +// (видимую) карту колонны col (или в пустую колонну — только +// король). +func (g *KlondikeGameState) canPlaceOnTableau(col int, card Card) bool { + cards := g.Tableau[col] + if len(cards) == 0 { + return card.Rank == King + } + top := cards[len(cards)-1] + if !top.FaceUp { + return false + } + return int(card.Rank)+1 == int(top.Card.Rank) && isRedSuit(card.Suit) != isRedSuit(top.Card.Suit) +} + +// canPlaceOnFoundation проверяет, можно ли положить card на +// фундамент её масти прямо сейчас. +func (g *KlondikeGameState) canPlaceOnFoundation(card Card) bool { + return int(card.Rank) == g.Foundations[card.Suit]+1 +} + +func (g *KlondikeGameState) flipTopIfNeeded(col int) { + cards := g.Tableau[col] + if len(cards) > 0 && !cards[len(cards)-1].FaceUp { + g.Tableau[col][len(cards)-1].FaceUp = true + } +} + +func (g *KlondikeGameState) checkWin() { + for _, r := range g.Foundations { + if r != int(King) { + return + } + } + g.Won = true +} + +// MoveWasteToTableau перекладывает верхнюю карту отбоя в колонну col. +func (g *KlondikeGameState) MoveWasteToTableau(col int) error { + if len(g.Waste) == 0 { + return ErrKlondikeEmptySource + } + card := g.Waste[len(g.Waste)-1] + if !g.canPlaceOnTableau(col, card) { + return ErrKlondikeInvalidTableau + } + g.pushHistory() + g.Waste = g.Waste[:len(g.Waste)-1] + g.Tableau[col] = append(g.Tableau[col], KlondikeCard{Card: card, FaceUp: true}) + g.Moves++ + return nil +} + +// MoveWasteToFoundation перекладывает верхнюю карту отбоя на +// фундамент её масти. +func (g *KlondikeGameState) MoveWasteToFoundation() error { + if len(g.Waste) == 0 { + return ErrKlondikeEmptySource + } + card := g.Waste[len(g.Waste)-1] + if !g.canPlaceOnFoundation(card) { + return ErrKlondikeInvalidFoundation + } + g.pushHistory() + g.Waste = g.Waste[:len(g.Waste)-1] + g.Foundations[card.Suit] = int(card.Rank) + g.Moves++ + g.checkWin() + return nil +} + +// MoveTableauToFoundation перекладывает верхнюю (видимую) карту +// колонны from на фундамент её масти. +func (g *KlondikeGameState) MoveTableauToFoundation(from int) error { + cards := g.Tableau[from] + if len(cards) == 0 { + return ErrKlondikeEmptySource + } + top := cards[len(cards)-1] + if !top.FaceUp { + return ErrKlondikeNotMovable + } + if !g.canPlaceOnFoundation(top.Card) { + return ErrKlondikeInvalidFoundation + } + g.pushHistory() + g.Tableau[from] = cards[:len(cards)-1] + g.Foundations[top.Card.Suit] = int(top.Card.Rank) + g.flipTopIfNeeded(from) + g.Moves++ + g.checkWin() + return nil +} + +// MoveTableauToTableau перемещает наибольшую корректную +// последовательность в конце колонны from в конец колонны to. +func (g *KlondikeGameState) MoveTableauToTableau(from, to int) error { + if from == to { + return ErrKlondikeInvalidTableau + } + run := g.movableRun(from) + if run == 0 { + return ErrKlondikeEmptySource + } + cards := g.Tableau[from] + moving := cards[len(cards)-run:] + if !g.canPlaceOnTableau(to, moving[0].Card) { + return ErrKlondikeInvalidTableau + } + + g.pushHistory() + movingCopy := append([]KlondikeCard{}, moving...) + g.Tableau[from] = cards[:len(cards)-run] + g.Tableau[to] = append(g.Tableau[to], movingCopy...) + g.flipTopIfNeeded(from) + g.Moves++ + return nil +} diff --git a/klondike_game_test.go b/klondike_game_test.go new file mode 100644 index 0000000..cb64eb0 --- /dev/null +++ b/klondike_game_test.go @@ -0,0 +1,253 @@ +package main + +import "testing" + +func TestNewKlondikeGameDealsCorrectly(t *testing.T) { + g := NewKlondikeGame() + total := 0 + for col := 0; col < 7; col++ { + if len(g.Tableau[col]) != col+1 { + t.Errorf("колонна %d: ожидалось %d карт, получено %d", col, col+1, len(g.Tableau[col])) + } + for i, kc := range g.Tableau[col] { + wantFaceUp := i == len(g.Tableau[col])-1 + if kc.FaceUp != wantFaceUp { + t.Errorf("колонна %d, карта %d: ожидалось FaceUp=%v, получено %v", col, i, wantFaceUp, kc.FaceUp) + } + } + total += len(g.Tableau[col]) + } + if total != 28 { + t.Fatalf("ожидалось 28 карт в колоннах (1+2+..+7), получено %d", total) + } + if len(g.Stock) != 52-28 { + t.Fatalf("ожидалось %d карт в колоде добора, получено %d", 52-28, len(g.Stock)) + } +} + +func TestDrawStockMovesToWaste(t *testing.T) { + g := NewKlondikeGame() + stockBefore := len(g.Stock) + top := g.Stock[len(g.Stock)-1] + + if err := g.DrawStock(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Stock) != stockBefore-1 { + t.Fatalf("колода добора должна была уменьшиться на 1") + } + if len(g.Waste) != 1 || g.Waste[0] != top { + t.Fatalf("верхняя карта колоды должна была оказаться в отбое") + } +} + +func TestDrawStockRecyclesWasteWhenEmpty(t *testing.T) { + g := NewKlondikeGame() + g.Stock = []Card{c(Two, Hearts), c(Three, Hearts)} + g.Waste = nil + if err := g.DrawStock(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.DrawStock(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Stock) != 0 { + t.Fatalf("колода добора должна была опустеть, осталось %d", len(g.Stock)) + } + if len(g.Waste) != 2 { + t.Fatalf("в отбое должно быть 2 карты, получено %d", len(g.Waste)) + } + + if err := g.DrawStock(); err != nil { + t.Fatalf("неожиданная ошибка пересдачи: %v", err) + } + // пересдача (переворот отбоя в колоду) — самостоятельное + // действие; она не добирает карту автоматически в этом же вызове + if len(g.Stock) != 2 || len(g.Waste) != 0 { + t.Fatalf("после пересдачи ожидалось 2 карты в колоде и 0 в отбое, получено stock=%d waste=%d", + len(g.Stock), len(g.Waste)) + } + if err := g.DrawStock(); err != nil { + t.Fatalf("неожиданная ошибка добора после пересдачи: %v", err) + } + if len(g.Stock) != 1 || len(g.Waste) != 1 { + t.Fatalf("после добора ожидалось 1 карта в колоде и 1 в отбое, получено stock=%d waste=%d", + len(g.Stock), len(g.Waste)) + } +} + +func TestDrawStockErrorsWhenBothEmpty(t *testing.T) { + g := NewKlondikeGame() + g.Stock = nil + g.Waste = nil + if err := g.DrawStock(); err != ErrKlondikeEmptySource { + t.Errorf("ожидалась ErrKlondikeEmptySource, получено: %v", err) + } +} + +func TestCanPlaceOnTableauDescendingAlternating(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{{Card: c(Seven, Hearts), FaceUp: true}} + + if !g.canPlaceOnTableau(0, c(Six, Spades)) { + t.Errorf("чёрная шестёрка должна ложиться на красную семёрку") + } + if g.canPlaceOnTableau(0, c(Six, Hearts)) { + t.Errorf("красная шестёрка не должна ложиться на красную семёрку (нужен другой цвет)") + } + if g.canPlaceOnTableau(0, c(Five, Spades)) { + t.Errorf("пятёрка не должна ложиться на семёрку (не на 1 меньше)") + } +} + +func TestCanPlaceOnEmptyTableauOnlyKing(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = nil + if !g.canPlaceOnTableau(0, c(King, Clubs)) { + t.Errorf("король должен ложиться в пустую колонну") + } + if g.canPlaceOnTableau(0, c(Queen, Clubs)) { + t.Errorf("дама не должна ложиться в пустую колонну") + } +} + +func TestMoveWasteToTableau(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{{Card: c(Seven, Hearts), FaceUp: true}} + g.Waste = []Card{c(Six, Spades)} + + if err := g.MoveWasteToTableau(0); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Waste) != 0 { + t.Errorf("отбой должен был опустеть") + } + if len(g.Tableau[0]) != 2 { + t.Fatalf("ожидалось 2 карты в колонне, получено %d", len(g.Tableau[0])) + } +} + +func TestMoveWasteToFoundation(t *testing.T) { + g := NewKlondikeGame() + g.Waste = []Card{c(Ace, Hearts)} + if err := g.MoveWasteToFoundation(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Foundations[Hearts] != int(Ace) { + t.Errorf("фундамент червей должен был получить туза") + } + + g.Waste = []Card{c(Three, Hearts)} + if err := g.MoveWasteToFoundation(); err != ErrKlondikeInvalidFoundation { + t.Errorf("ожидалась ошибка (нужна сначала двойка), получено: %v", err) + } +} + +func TestMoveTableauToFoundationFlipsNextCard(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{ + {Card: c(Five, Clubs), FaceUp: false}, + {Card: c(Ace, Hearts), FaceUp: true}, + } + if err := g.MoveTableauToFoundation(0); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Tableau[0]) != 1 { + t.Fatalf("в колонне должна была остаться 1 карта") + } + if !g.Tableau[0][0].FaceUp { + t.Errorf("следующая карта в колонне должна была открыться") + } +} + +func TestMoveTableauToTableauMovesValidRun(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{ + {Card: c(Nine, Clubs), FaceUp: false}, + {Card: c(Seven, Hearts), FaceUp: true}, + {Card: c(Six, Spades), FaceUp: true}, + {Card: c(Five, Hearts), FaceUp: true}, + } + g.Tableau[1] = []KlondikeCard{ + {Card: c(Eight, Spades), FaceUp: true}, + } + + if err := g.MoveTableauToTableau(0, 1); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Tableau[0]) != 1 { + t.Fatalf("в исходной колонне должна была остаться 1 карта (девятка рубашкой), получено %d", len(g.Tableau[0])) + } + if !g.Tableau[0][0].FaceUp { + t.Errorf("оставшаяся девятка должна была автоматически открыться — в этом весь смысл пасьянса") + } + if len(g.Tableau[1]) != 4 { + t.Fatalf("в целевой колонне должно было стать 4 карты, получено %d", len(g.Tableau[1])) + } +} + +func TestMoveTableauToTableauRejectsInvalidDestination(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{{Card: c(Seven, Hearts), FaceUp: true}} + g.Tableau[1] = []KlondikeCard{{Card: c(King, Spades), FaceUp: true}} + + if err := g.MoveTableauToTableau(0, 1); err != ErrKlondikeInvalidTableau { + t.Errorf("семёрка не должна ложиться на короля, ожидалась ошибка, получено: %v", err) + } +} + +func TestMovableRunStopsAtBrokenSequence(t *testing.T) { + g := NewKlondikeGame() + g.Tableau[0] = []KlondikeCard{ + {Card: c(Nine, Clubs), FaceUp: true}, + {Card: c(Six, Spades), FaceUp: true}, + {Card: c(Five, Hearts), FaceUp: true}, + } + run := g.movableRun(0) + if run != 2 { + t.Errorf("ожидалась цепочка длиной 2 (6-5), получено %d", run) + } +} + +func TestUndoRestoresPreviousState(t *testing.T) { + g := NewKlondikeGame() + g.Waste = []Card{c(Ace, Hearts)} + if err := g.MoveWasteToFoundation(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Foundations[Hearts] != int(Ace) { + t.Fatalf("ход должен был примениться") + } + + if err := g.Undo(); err != nil { + t.Fatalf("неожиданная ошибка отмены: %v", err) + } + if g.Foundations[Hearts] != 0 { + t.Errorf("после отмены фундамент должен был вернуться в исходное состояние") + } + if len(g.Waste) != 1 { + t.Errorf("после отмены карта должна была вернуться в отбой") + } +} + +func TestUndoErrorsWhenNoHistory(t *testing.T) { + g := NewKlondikeGame() + if err := g.Undo(); err != ErrKlondikeNoUndo { + t.Errorf("ожидалась ErrKlondikeNoUndo, получено: %v", err) + } +} + +func TestWinDetection(t *testing.T) { + g := NewKlondikeGame() + for s := Clubs; s <= Spades; s++ { + g.Foundations[s] = int(King) + } + g.Foundations[Hearts] = int(Queen) + g.Waste = []Card{c(King, Hearts)} + if err := g.MoveWasteToFoundation(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Won { + t.Errorf("все 4 фундамента на короле должны означать победу") + } +} diff --git a/klondike_tui.go b/klondike_tui.go new file mode 100644 index 0000000..66bec7c --- /dev/null +++ b/klondike_tui.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// klondikePileRef — ссылка на стопку: колонна (0-6), отбой (-1) или +// "ничего не выбрано" (-2). +type klondikePileRef int + +const ( + klondikeNoSelection klondikePileRef = -2 + klondikeWasteRef klondikePileRef = -1 +) + +// KlondikeModel — TUI поверх KlondikeGameState. Управление в два +// шага: сначала выбираем источник (стопку, откуда берём карты), +// затем — куда их переложить. +type KlondikeModel struct { + game *KlondikeGameState + selected klondikePileRef + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewKlondikeModel создаёт новую партию пасьянса. +func NewKlondikeModel() KlondikeModel { + return KlondikeModel{ + game: NewKlondikeGame(), + selected: klondikeNoSelection, + } +} + +func (m KlondikeModel) Init() tea.Cmd { + return nil +} + +func (m *KlondikeModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *KlondikeModel) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m *KlondikeModel) clearSelection() { + m.selected = klondikeNoSelection +} + +func (m KlondikeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + return m.handleKey(keyMsg) +} + +func (m KlondikeModel) 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.Won { + if key == "n" { + m.game = NewKlondikeGame() + m.clearSelection() + m.message = "" + m.isError = false + } + return m, nil + } + + switch key { + case "n": + m.game = NewKlondikeGame() + m.clearSelection() + m.message = "" + m.isError = false + return m, nil + case "u": + if err := m.game.Undo(); err != nil { + m.setError(err) + return m, nil + } + m.clearSelection() + m.setInfo("Ход отменён.") + return m, nil + case "esc": + m.clearSelection() + m.message = "" + return m, nil + case "d": + m.clearSelection() + if err := m.game.DrawStock(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Взяли карту из колоды.") + return m, nil + case "1", "2", "3", "4", "5", "6", "7": + col := int(key[0] - '1') + return m.handlePileKey(klondikePileRef(col)) + case "0": + return m.handlePileKey(klondikeWasteRef) + case "f": + return m.handleFoundationKey() + } + return m, nil +} + +// handlePileKey обрабатывает нажатие клавиши колонны (1-7) или +// отбоя (0): если ничего не выбрано — пытается выбрать эту стопку +// как источник; если что-то уже выбрано — пытается переложить туда. +func (m KlondikeModel) handlePileKey(ref klondikePileRef) (tea.Model, tea.Cmd) { + if m.selected == klondikeNoSelection { + if !m.pileHasMovableCard(ref) { + m.setError(ErrKlondikeEmptySource) + return m, nil + } + m.selected = ref + m.message = "" + return m, nil + } + + if m.selected == ref { + m.clearSelection() + return m, nil + } + + var err error + if m.selected == klondikeWasteRef { + err = m.game.MoveWasteToTableau(int(ref)) + } else { + err = m.game.MoveTableauToTableau(int(m.selected), int(ref)) + } + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Ход выполнен.") + m.clearSelection() + return m, nil +} + +func (m KlondikeModel) handleFoundationKey() (tea.Model, tea.Cmd) { + if m.selected == klondikeNoSelection { + m.setError(ErrKlondikeEmptySource) + return m, nil + } + var err error + if m.selected == klondikeWasteRef { + err = m.game.MoveWasteToFoundation() + } else { + if m.game.movableRun(int(m.selected)) != 1 { + err = ErrKlondikeMultiToFoundation + } else { + err = m.game.MoveTableauToFoundation(int(m.selected)) + } + } + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Карта отправлена на фундамент.") + m.clearSelection() + return m, nil +} + +func (m KlondikeModel) pileHasMovableCard(ref klondikePileRef) bool { + if ref == klondikeWasteRef { + return len(m.game.Waste) > 0 + } + return m.game.movableRun(int(ref)) > 0 +} + +// --- Отрисовка ----------------------------------------------------- + +func (m KlondikeModel) renderFoundations() string { + var parts []string + suits := []Suit{Clubs, Diamonds, Hearts, Spades} + for _, s := range suits { + rank := m.game.Foundations[s] + if rank == 0 { + parts = append(parts, dimStyle.Render("["+s.String()+" --]")) + continue + } + card := Card{Suit: s, Rank: Rank(rank)} + parts = append(parts, "["+renderCard(card)+"]") + } + return strings.Join(parts, " ") +} + +func (m KlondikeModel) renderStockWaste() string { + stockStr := dimStyle.Render(fmt.Sprintf("(%d)", len(m.game.Stock))) + wasteStr := dimStyle.Render("(пусто)") + if len(m.game.Waste) > 0 { + top := m.game.Waste[len(m.game.Waste)-1] + wasteStr = renderCard(top) + if m.selected == klondikeWasteRef { + wasteStr = cursorStyle.Render(top.String()) + } + } + return fmt.Sprintf("Колода: %s Отбой: %s", stockStr, wasteStr) +} + +func (m KlondikeModel) renderTableau() string { + var rows []string + maxLen := 0 + for _, col := range m.game.Tableau { + if len(col) > maxLen { + maxLen = len(col) + } + } + + header := make([]string, 7) + for i := range header { + label := fmt.Sprintf(" %d ", i+1) + if m.selected == klondikePileRef(i) { + label = cursorStyle.Render(label) + } else { + label = dimStyle.Render(label) + } + header[i] = label + } + rows = append(rows, strings.Join(header, " ")) + + for row := 0; row < maxLen; row++ { + cells := make([]string, 7) + for col := 0; col < 7; col++ { + if row >= len(m.game.Tableau[col]) { + cells[col] = " " + continue + } + kc := m.game.Tableau[col][row] + if !kc.FaceUp { + cells[col] = dimStyle.Render("[?]") + continue + } + text := renderCard(kc.Card) + isTopOfSelectedRun := m.selected == klondikePileRef(col) && + row >= len(m.game.Tableau[col])-m.game.movableRun(col) + if isTopOfSelectedRun { + text = cursorStyle.Render(kc.Card.String()) + } + cells[col] = text + } + rows = append(rows, strings.Join(cells, " ")) + } + return strings.Join(rows, "\n") +} + +func (m KlondikeModel) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== КОСЫНКА ===")) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Фундаменты: %s\n", m.renderFoundations()) + fmt.Fprintln(&b, m.renderStockWaste()) + fmt.Fprintf(&b, "Ходов: %d\n\n", m.game.Moves) + fmt.Fprintln(&b, m.renderTableau()) + fmt.Fprintln(&b) + + if m.game.Won { + fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Пасьянс сошёлся за %d ходов! Поздравляем!", m.game.Moves))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + return b.String() + } + + if m.selected == klondikeNoSelection { + fmt.Fprintln(&b, "[1-7] выбрать колонну [0] выбрать отбой [d] взять из колоды") + } else { + fmt.Fprintln(&b, "[1-7] переложить в колонну [f] на фундамент [esc] отменить выбор [d] взять из колоды") + } + fmt.Fprintln(&b, dimStyle.Render("[u] отменить ход [n] новая партия [q] в меню")) + + 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() +} diff --git a/klondike_tui_test.go b/klondike_tui_test.go new file mode 100644 index 0000000..e3c1f30 --- /dev/null +++ b/klondike_tui_test.go @@ -0,0 +1,161 @@ +package main + +import "testing" + +func TestKlondikeModelDrawViaKeys(t *testing.T) { + m := NewKlondikeModel() + stockBefore := len(m.game.Stock) + + next, _ := m.Update(key("d")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if len(m.game.Stock) != stockBefore-1 { + t.Fatalf("колода должна была уменьшиться на 1") + } +} + +func TestKlondikeModelSelectAndMoveTableauToTableau(t *testing.T) { + m := NewKlondikeModel() + m.game.Tableau[0] = []KlondikeCard{{Card: c(Seven, Hearts), FaceUp: true}} + m.game.Tableau[1] = []KlondikeCard{{Card: c(Six, Spades), FaceUp: true}} + + next, _ := m.Update(key("2")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора: %s", m.message) + } + if m.selected != klondikePileRef(1) { + t.Fatalf("ожидался выбор колонны 1 (индекс), получено %v", m.selected) + } + + next, _ = m.Update(key("1")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода: %s", m.message) + } + if len(m.game.Tableau[0]) != 2 { + t.Fatalf("ожидалось 2 карты в целевой колонне, получено %d", len(m.game.Tableau[0])) + } + if m.selected != klondikeNoSelection { + t.Errorf("выбор должен был сброситься после хода") + } +} + +func TestKlondikeModelSelectWasteAndSendToFoundation(t *testing.T) { + m := NewKlondikeModel() + m.game.Waste = []Card{c(Ace, Hearts)} + + next, _ := m.Update(key("0")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.selected != klondikeWasteRef { + t.Fatalf("ожидался выбор отбоя") + } + + next, _ = m.Update(key("f")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка отправки на фундамент: %s", m.message) + } + if m.game.Foundations[Hearts] != int(Ace) { + t.Errorf("туз червей должен был оказаться на фундаменте") + } +} + +func TestKlondikeModelEscClearsSelection(t *testing.T) { + m := NewKlondikeModel() + m.game.Tableau[0] = []KlondikeCard{{Card: c(Seven, Hearts), FaceUp: true}} + + next, _ := m.Update(key("1")) + m = next.(KlondikeModel) + if m.selected == klondikeNoSelection { + t.Fatalf("ожидался выбор колонны 1") + } + + next, _ = m.Update(key("esc")) + m = next.(KlondikeModel) + if m.selected != klondikeNoSelection { + t.Errorf("esc должен был сбросить выбор") + } +} + +func TestKlondikeModelUndoViaKeys(t *testing.T) { + m := NewKlondikeModel() + m.game.Waste = []Card{c(Ace, Hearts)} + next, _ := m.Update(key("0")) + m = next.(KlondikeModel) + next, _ = m.Update(key("f")) + m = next.(KlondikeModel) + if m.game.Foundations[Hearts] != int(Ace) { + t.Fatalf("ход должен был примениться") + } + + next, _ = m.Update(key("u")) + m = next.(KlondikeModel) + if m.isError { + t.Fatalf("неожиданная ошибка отмены: %s", m.message) + } + if m.game.Foundations[Hearts] != 0 { + t.Errorf("после отмены фундамент должен был вернуться в исходное состояние") + } +} + +func TestKlondikeModelNewGameViaKeys(t *testing.T) { + m := NewKlondikeModel() + oldStock := m.game.Stock + + next, _ := m.Update(key("n")) + m = next.(KlondikeModel) + if len(m.game.Stock)+28 != 52 { + t.Fatalf("новая партия должна быть корректно раздана") + } + same := true + for i := range oldStock { + if i >= len(m.game.Stock) || oldStock[i] != m.game.Stock[i] { + same = false + break + } + } + if same { + t.Errorf("новая партия должна была перетасовать колоду заново") + } +} + +func TestKlondikeModelQReturnsToMenu(t *testing.T) { + m := NewKlondikeModel() + next, _ := m.Update(key("q")) + m = next.(KlondikeModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestKlondikeModelWinShowsResultAndAllowsNewGame(t *testing.T) { + m := NewKlondikeModel() + for s := Clubs; s <= Spades; s++ { + m.game.Foundations[s] = int(King) + } + m.game.Foundations[Hearts] = int(Queen) + m.game.Waste = []Card{c(King, Hearts)} + + next, _ := m.Update(key("0")) + m = next.(KlondikeModel) + next, _ = m.Update(key("f")) + m = next.(KlondikeModel) + if !m.game.Won { + t.Fatalf("партия должна была завершиться победой") + } + + next, _ = m.Update(key("n")) + m = next.(KlondikeModel) + if m.game.Won { + t.Errorf("новая партия не должна была начаться выигранной") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e335216 --- /dev/null +++ b/main.go @@ -0,0 +1,269 @@ +package main + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "-h", "--help": + printHelp() + return + case "-sim": + runSimulation() + return + case "-sim-durak": + runDurakSimulation() + return + case "-sim-blackjack": + runBlackjackSimulation() + return + case "-sim-101": + runOneOhOneSimulation() + return + case "-sim-1000": + runThousandSimulation() + return + } + } + + p := tea.NewProgram(NewAppModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("ошибка TUI: %v\n", err) + os.Exit(1) + } +} + +// printHelp выводит общее описание запуска и правила всех игр из +// реестра — доступно без захода в TUI, годится для чтения через +// `go-games-collection --help | less` или на непривычной к TUI +// системе. +func printHelp() { + fmt.Println(asciiBanner) + fmt.Println("Коллекция консольных игр на Go") + fmt.Println() + fmt.Println("ЗАПУСК") + fmt.Println(" go-games-collection открыть меню коллекции") + fmt.Println(" go-games-collection -sim headless-симуляция ботов Тонка (без TUI)") + fmt.Println(" go-games-collection -sim-durak headless-симуляция ботов Дурака (без TUI)") + fmt.Println(" go-games-collection -sim-blackjack headless-симуляция ботов Блэкджека (без TUI)") + fmt.Println(" go-games-collection -sim-101 headless-симуляция ботов 101 (без TUI)") + fmt.Println(" go-games-collection -sim-1000 headless-симуляция ботов 1000 (без TUI)") + fmt.Println(" go-games-collection --help показать эту справку и правила игр") + fmt.Println() + for _, g := range gameRegistry { + separator := strings.Repeat("=", len(g.Name)+4) + fmt.Println(separator) + fmt.Printf(" %s\n", g.Name) + fmt.Println(separator) + fmt.Println() + fmt.Println(g.Rules) + } +} + +// runSimulation — headless-прогон партии из 4 ботов разного +// уровня, без TUI. Полезно для быстрой проверки баланса и +// поведения ботов: `go run . -sim`. +func runSimulation() { + names, bots := newBotRoster(4) + g := NewGame(names, -1) + + const maxTurns = 500 + for turn := 0; turn < maxTurns && g.Phase != PhaseGameOver; turn++ { + bot := bots[g.CurrentPlayer] + if err := bot.PlayTurn(g); err != nil { + fmt.Printf("ошибка на ходу %s: %v\n", g.Players[g.CurrentPlayer].Name, err) + break + } + } + + if g.Result == nil { + fmt.Println("партия не завершилась за отведённое число ходов") + return + } + + switch { + case g.Result.DeadRound: + fmt.Printf("раунд закончился вничью: колода и сброс исчерпаны (банк %d возвращён игрокам)\n", g.Result.Pot) + case g.Result.TonkCall: + fmt.Printf("%s объявил(а) Тонк и забрал(а) банк ×%d (%d)!\n", g.Players[g.Result.Winner].Name, g.TonkMultiplier, g.Result.Pot*g.TonkMultiplier) + default: + fmt.Printf("%s избавился(лась) от всех карт и забрал(а) банк (%d)!\n", g.Players[g.Result.Winner].Name, g.Result.Pot) + } + for i, p := range g.Players { + fmt.Printf(" %-20s изменение капитала: %+d (итого: %d)\n", p.Name, g.Result.BalanceDeltas[i], p.Balance) + } +} + +// runDurakSimulation — headless-прогон партии Дурака ботами, без +// TUI: `go run . -sim-durak`. Число игроков фиксировано (4) для +// простоты; для проверки других раскладов используйте TUI. +func runDurakSimulation() { + names := newDurakBotNames(4) + g := NewDurakGame(names, -1) + bot := DurakBot{} + + const maxSteps = 10000 + steps := 0 + for ; steps < maxSteps && g.Phase != DurakPhaseGameOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + fmt.Printf("ошибка на шаге %d: %v\n", steps, err) + return + } + } + + if g.Result == nil { + fmt.Println("партия не завершилась за отведённое число шагов") + return + } + + switch { + case g.Result.Stalemate: + fmt.Println("партия зашла в тупик и принудительно завершена вничью") + case g.Result.NoLoser: + fmt.Println("ничья: все избавились от карт одновременно") + default: + fmt.Printf("%s остался(лась) с картами на руках — дурак!\n", g.Players[g.Result.Loser].Name) + } + for _, p := range g.Players { + status := "в игре" + if p.Out { + status = "отбился" + } + fmt.Printf(" %-10s карт: %-2d (%s)\n", p.Name, len(p.Hand), status) + } +} + +// runBlackjackSimulation — headless-прогон нескольких раундов +// блэкджека ботами, без TUI: `go run . -sim-blackjack`. Число +// игроков фиксировано (3) для простоты. +func runBlackjackSimulation() { + names, _ := newBotRoster(3) + g := NewBlackjackGame(names, -1, 1000, 10) + bot := BlackjackBot{} + + const rounds = 5 + for round := 0; round < rounds; round++ { + const maxSteps = 100 + for steps := 0; steps < maxSteps && g.Phase != BJPhaseRoundOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + fmt.Printf("ошибка на шаге %d: %v\n", steps, err) + return + } + } + if g.Result == nil { + fmt.Println("раунд не завершился за отведённое число шагов") + return + } + + fmt.Printf("--- Раунд %d --- дилер: %d", round+1, g.Result.DealerTotal) + if g.Result.DealerBusted { + fmt.Print(" (перебор)") + } + fmt.Println() + for i, p := range g.Players { + r := g.Result.PlayerResults[i] + fmt.Printf(" %-10s %-10s изменение капитала: %+d (итого: %d)\n", p.Name, r.Outcome, r.Delta, p.Balance) + } + + if round < rounds-1 { + g = g.NextRound() + } + } +} + +// runOneOhOneSimulation — headless-прогон полного матча 101 +// ботами, без TUI: `go run . -sim-101`. Число игроков фиксировано +// (4) для простоты. +func runOneOhOneSimulation() { + names := newOneOhOneBotNames(4) + g := NewOneOhOneGame(names, -1) + bot := OneOhOneBot{} + + const maxRounds = 200 + for round := 0; round < maxRounds; round++ { + const maxSteps = 6000 + steps := 0 + for ; steps < maxSteps && g.Phase != OneOhOnePhaseRoundOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + fmt.Printf("ошибка на шаге %d: %v\n", steps, err) + return + } + } + if g.Result == nil { + fmt.Println("раунд не завершился за отведённое число шагов") + return + } + + if g.Result.Stalemate { + fmt.Printf("--- Раунд %d: тупик, счёт не изменился ---\n", round+1) + } else { + winner := g.Players[g.Result.Winner].Name + fmt.Printf("--- Раунд %d: %s избавился(лась) от карт первым(ой)", round+1, winner) + if g.Result.QueenBonus { + fmt.Print(" (бонус за даму!)") + } + fmt.Println() + for i, p := range g.Players { + fmt.Printf(" %-10s %+-4d очков за раунд (итого: %d)\n", p.Name, g.Result.ScoreDeltas[i], p.Score) + } + } + + if g.Result.MatchOver { + if g.Result.NoWinner { + fmt.Println("Партия завершилась без победителя.") + } else { + fmt.Printf("Партия окончена! Победитель: %s\n", g.Players[g.Result.MatchWinner].Name) + } + return + } + g.NextRound() + } + fmt.Println("матч не завершился за отведённое число раундов") +} + +// runThousandSimulation — headless-прогон полного матча 1000 +// ботами, без TUI: `go run . -sim-1000`. +func runThousandSimulation() { + names := newThousandBotNames(3) + g := NewThousandGame(names, -1) + bot := ThousandBot{} + + const maxHands = 500 + for hand := 0; hand < maxHands; hand++ { + const maxSteps = 2000 + steps := 0 + for ; steps < maxSteps && g.Phase != ThousandPhaseHandOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + fmt.Printf("ошибка на шаге %d (фаза %v): %v\n", steps, g.Phase, err) + return + } + } + if g.Result == nil { + fmt.Println("раздача не завершилась за отведённое число шагов") + return + } + + declarer := g.Players[g.Result.DeclarerIdx].Name + status := "выполнил(а)" + if !g.Result.Made { + status = "не выполнил(а)" + } + fmt.Printf("--- Раздача %d: %s %s ставку %d ---\n", hand+1, declarer, status, g.Result.FinalBid) + for i, p := range g.Players { + fmt.Printf(" %-10s %+-5d очков (итого: %d)\n", p.Name, g.Result.ScoreDeltas[i], p.Score) + } + + if g.Result.MatchOver { + fmt.Printf("Партия окончена! Победитель: %s\n", g.Players[g.Result.MatchWinner].Name) + return + } + g.NextHand() + } + fmt.Println("матч не завершился за отведённое число раздач") +} diff --git a/meld.go b/meld.go new file mode 100644 index 0000000..64c18e5 --- /dev/null +++ b/meld.go @@ -0,0 +1,178 @@ +package main + +import "sort" + +// IsSet проверяет, что переданные карты образуют валидный сет: +// 3 и более карты одного ранга, все с разными мастями (иначе была +// бы дублирующаяся карта, что невозможно в одной колоде, но +// проверка на всякий случай — например, при игре с несколькими +// колодами). +func IsSet(cards []Card) bool { + if len(cards) < 3 { + return false + } + rank := cards[0].Rank + seenSuits := make(map[Suit]bool, len(cards)) + for _, c := range cards { + if c.Rank != rank { + return false + } + if seenSuits[c.Suit] { + return false + } + seenSuits[c.Suit] = true + } + return true +} + +// IsSequence проверяет, что карты образуют валидный сиквенс: +// 3 и более карты одной масти подряд по рангу. Туз может считаться +// низким (A-2-3) или высоким (Q-K-A), но не одновременно и то, и +// другое (K-A-2 невалиден). +func IsSequence(cards []Card) bool { + if len(cards) < 3 { + return false + } + suit := cards[0].Suit + ranks := make([]int, 0, len(cards)) + for _, c := range cards { + if c.Suit != suit { + return false + } + ranks = append(ranks, int(c.Rank)) + } + sort.Ints(ranks) + + // нет дубликатов рангов + for i := 1; i < len(ranks); i++ { + if ranks[i] == ranks[i-1] { + return false + } + } + + if isConsecutive(ranks) { + return true + } + + // вариант с тузом как старшей картой: заменяем 1 (Ace) на 14 + // и пробуем снова. + if ranks[0] == int(Ace) { + aceHigh := make([]int, len(ranks)) + copy(aceHigh, ranks[1:]) + aceHigh[len(aceHigh)-1] = 14 + sort.Ints(aceHigh) + return isConsecutive(aceHigh) + } + + return false +} + +func isConsecutive(sortedRanks []int) bool { + for i := 1; i < len(sortedRanks); i++ { + if sortedRanks[i] != sortedRanks[i-1]+1 { + return false + } + } + return true +} + +// FindAllSets группирует карты руки по рангу и возвращает те +// группы, что образуют валидный сет (3+ карты). +func FindAllSets(hand []Card) [][]Card { + byRank := make(map[Rank][]Card) + for _, c := range hand { + byRank[c.Rank] = append(byRank[c.Rank], c) + } + var sets [][]Card + for _, group := range byRank { + if IsSet(group) { + sets = append(sets, group) + } + } + return sets +} + +// FindAllSequences группирует карты руки по масти, находит все +// максимальные последовательности подряд идущих рангов длиной 3+ +// и возвращает их. Учитывает и туз-снизу, и туз-сверху отдельно, +// если рука позволяет оба варианта (сама карта туза при этом +// используется только один раз в каждом найденном варианте). +func FindAllSequences(hand []Card) [][]Card { + bySuit := make(map[Suit][]Card) + for _, c := range hand { + bySuit[c.Suit] = append(bySuit[c.Suit], c) + } + + var result [][]Card + for _, cards := range bySuit { + result = append(result, findRunsInSuit(cards)...) + } + return result +} + +// findRunsInSuit находит все максимальные подряд идущие отрезки +// (длиной 3+) внутри одной масти, включая отдельную попытку с +// тузом как старшей картой. +func findRunsInSuit(cards []Card) [][]Card { + byRank := make(map[int]Card) + for _, c := range cards { + byRank[int(c.Rank)] = c + } + + var runs [][]Card + runs = append(runs, extractRuns(byRank, 1, 13, false)...) + + if _, hasAce := byRank[int(Ace)]; hasAce { + runs = append(runs, extractRuns(byRank, 2, 14, true)...) + } + return runs +} + +// extractRuns проходит по диапазону [lo, hi] и собирает +// максимальные подряд идущие последовательности длиной 3+. +// Если aceHigh=true, ранг 14 интерпретируется как туз. +func extractRuns(byRank map[int]Card, lo, hi int, aceHigh bool) [][]Card { + var runs [][]Card + var current []Card + + flush := func() { + if len(current) >= 3 { + runCopy := make([]Card, len(current)) + copy(runCopy, current) + runs = append(runs, runCopy) + } + current = nil + } + + for r := lo; r <= hi; r++ { + rank := r + if aceHigh && r == 14 { + rank = int(Ace) + } + c, ok := byRank[rank] + if ok { + current = append(current, c) + } else { + flush() + } + } + flush() + return runs +} + +// HandValue возвращает суммарную очковую стоимость карт на руке — +// используется и для расчёта штрафа проигравшего, и для проверки +// возможности объявить Тонк. +func HandValue(hand []Card) int { + total := 0 + for _, c := range hand { + total += c.Value() + } + return total +} + +// CanDeclareTonk проверяет, можно ли объявить Тонк при данной руке: +// суммарная стоимость карт должна быть не больше threshold. +func CanDeclareTonk(hand []Card, threshold int) bool { + return HandValue(hand) <= threshold +} diff --git a/meld_test.go b/meld_test.go new file mode 100644 index 0000000..e162f87 --- /dev/null +++ b/meld_test.go @@ -0,0 +1,130 @@ +package main + +import "testing" + +func c(rank Rank, suit Suit) Card { + return Card{Rank: rank, Suit: suit} +} + +func TestIsSet(t *testing.T) { + valid := []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades)} + if !IsSet(valid) { + t.Errorf("ожидался валидный сет: %v", valid) + } + + tooFew := []Card{c(Seven, Clubs), c(Seven, Hearts)} + if IsSet(tooFew) { + t.Errorf("сет из 2 карт не должен быть валиден: %v", tooFew) + } + + mixedRank := []Card{c(Seven, Clubs), c(Eight, Hearts), c(Seven, Spades)} + if IsSet(mixedRank) { + t.Errorf("разные ранги не образуют сет: %v", mixedRank) + } + + dupSuit := []Card{c(Seven, Clubs), c(Seven, Clubs), c(Seven, Hearts)} + if IsSet(dupSuit) { + t.Errorf("дублирующиеся масти невалидны: %v", dupSuit) + } +} + +func TestIsSequenceBasic(t *testing.T) { + run := []Card{c(Five, Hearts), c(Six, Hearts), c(Seven, Hearts)} + if !IsSequence(run) { + t.Errorf("ожидался валидный сиквенс: %v", run) + } + + mixedSuit := []Card{c(Five, Hearts), c(Six, Clubs), c(Seven, Hearts)} + if IsSequence(mixedSuit) { + t.Errorf("разные масти не образуют сиквенс: %v", mixedSuit) + } + + gap := []Card{c(Five, Hearts), c(Seven, Hearts), c(Eight, Hearts)} + if IsSequence(gap) { + t.Errorf("сиквенс с пропуском невалиден: %v", gap) + } +} + +func TestIsSequenceAce(t *testing.T) { + aceLow := []Card{c(Ace, Spades), c(Two, Spades), c(Three, Spades)} + if !IsSequence(aceLow) { + t.Errorf("туз снизу должен быть валиден: %v", aceLow) + } + + aceHigh := []Card{c(Queen, Spades), c(King, Spades), c(Ace, Spades)} + if !IsSequence(aceHigh) { + t.Errorf("туз сверху должен быть валиден: %v", aceHigh) + } + + aceWrap := []Card{c(King, Spades), c(Ace, Spades), c(Two, Spades)} + if IsSequence(aceWrap) { + t.Errorf("туз не может замыкать колоду по кругу (K-A-2): %v", aceWrap) + } +} + +func TestFindAllSets(t *testing.T) { + hand := []Card{ + c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades), + c(Nine, Clubs), c(Two, Diamonds), + } + sets := FindAllSets(hand) + if len(sets) != 1 { + t.Fatalf("ожидался 1 сет, получено %d: %v", len(sets), sets) + } + if len(sets[0]) != 3 { + t.Errorf("ожидалось 3 карты в сете, получено %d", len(sets[0])) + } +} + +func TestFindAllSequences(t *testing.T) { + hand := []Card{ + c(Four, Hearts), c(Five, Hearts), c(Six, Hearts), c(Seven, Hearts), + c(Two, Clubs), + } + runs := FindAllSequences(hand) + if len(runs) != 1 { + t.Fatalf("ожидался 1 сиквенс, получено %d: %v", len(runs), runs) + } + if len(runs[0]) != 4 { + t.Errorf("ожидалась последовательность из 4 карт, получено %d", len(runs[0])) + } +} + +func TestHandValueAndTonk(t *testing.T) { + hand := []Card{c(Ace, Clubs), c(Two, Hearts), c(King, Spades)} + // 1 + 2 + 10 = 13 + if v := HandValue(hand); v != 13 { + t.Errorf("ожидалось значение руки 13, получено %d", v) + } + if CanDeclareTonk(hand, 5) { + t.Errorf("рука с весом 13 не должна позволять Тонк при пороге 5") + } + + lowHand := []Card{c(Ace, Clubs), c(Two, Hearts)} + if !CanDeclareTonk(lowHand, 5) { + t.Errorf("рука с весом 3 должна позволять Тонк при пороге 5") + } +} + +func TestDeckDrawsAllCards(t *testing.T) { + d := NewDeck() + if d.Len() != 52 { + t.Fatalf("ожидалось 52 карты, получено %d", d.Len()) + } + d.Shuffle() + + seen := make(map[Card]bool) + for { + card, ok := d.Draw() + if !ok { + break + } + if seen[card] { + t.Fatalf("карта %v встретилась дважды", card) + } + seen[card] = true + } + if len(seen) != 52 { + t.Fatalf("ожидалось 52 уникальные карты, получено %d", len(seen)) + } +} diff --git a/menu.go b/menu.go new file mode 100644 index 0000000..bd4472e --- /dev/null +++ b/menu.go @@ -0,0 +1,772 @@ +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// GameEntry — запись в реестре игр коллекции. Регистр сделан так, +// чтобы добавление новой игры сводилось к добавлению одной записи +// в gameRegistry, без изменений в логике меню. +type GameEntry struct { + Name string + Description string + Rules string + New func() tea.Model + + // UsesStakes — включает экран настройки ставки перед запуском: + // стартовый капитал, размер анте и множитель банка при особой + // победе (в Тонке — объявление Тонка). Если false, игра + // запускается сразу через New(), без экрана настроек. + UsesStakes bool + NewWithStakes func(startingCapital, ante, bonusMultiplier int) tea.Model + + // UsesPlayerCount — включает экран выбора количества игроков + // (2-6, включая человека) перед запуском. + UsesPlayerCount bool + NewWithPlayerCount func(numPlayers int) tea.Model + + // UsesBlackjackSettings — включает совмещённый экран настройки + // перед запуском: число игроков, стартовый капитал и размер + // ставки за раунд. Отдельный флаг вместо переиспользования + // UsesStakes/UsesPlayerCount, потому что Блэкджеку нужны все три + // параметра сразу на одном экране. + UsesBlackjackSettings bool + NewBlackjackWithSettings func(numPlayers, startingCapital, bet int) tea.Model + + // UsesCheckersSettings — включает экран выбора уровня сложности + // бота (простой/сильный) перед началом партии. + UsesCheckersSettings bool + NewCheckersWithDifficulty func(depth int) tea.Model +} + +// gameRegistry — список доступных игр коллекции. +var gameRegistry = []GameEntry{ + { + Name: "Тонк (Tonk)", + Description: "Карточная игра на 1 человека + 3 бота разного уровня сложности", + Rules: tonkRules, + New: func() tea.Model { return NewModel() }, + UsesStakes: true, + NewWithStakes: func(capital, ante, mult int) tea.Model { return NewModelWithStakes(capital, ante, mult) }, + }, + { + Name: "Дурак (Durak)", + Description: "Классическая карточная игра на 2-6 игроков (подкидной вариант)", + Rules: durakRules, + New: func() tea.Model { return NewDurakModel(4) }, + UsesPlayerCount: true, + NewWithPlayerCount: func(n int) tea.Model { return NewDurakModel(n) }, + }, + { + Name: "Блэкджек (Blackjack)", + Description: "Каждый играет против дилера, 1-6 игроков включая человека", + Rules: blackjackRules, + UsesBlackjackSettings: true, + NewBlackjackWithSettings: func(numPlayers, capital, bet int) tea.Model { + return NewBlackjackModel(numPlayers, capital, bet) + }, + }, + { + Name: "101", + Description: "Избавьтесь от карт первым; штрафные очки за оставшиеся на руках, до 101 — выбывание", + Rules: oneOhOneRules, + New: func() tea.Model { return NewOneOhOneModel(4) }, + UsesPlayerCount: true, + NewWithPlayerCount: func(n int) tea.Model { return NewOneOhOneModel(n) }, + }, + { + Name: "1000 (Тысяча)", + Description: "Взяточная игра с торгами и марьяжами, строго на троих (вы + 2 бота)", + Rules: thousandRules, + New: func() tea.Model { return NewThousandModel() }, + }, + { + Name: "Косынка (Klondike)", + Description: "Классический пасьянс на одного игрока, без ботов", + Rules: klondikeRules, + New: func() tea.Model { return NewKlondikeModel() }, + }, + { + Name: "Шашки (Checkers)", + Description: "Русские шашки против бота (минимакс), обязательное взятие", + Rules: checkersRules, + UsesCheckersSettings: true, + NewCheckersWithDifficulty: func(depth int) tea.Model { + return NewCheckersModel(depth) + }, + }, +} + +type appState int + +const ( + stateMenu appState = iota + stateRulesMenu + stateRules + stateSettings + statePlayerCount + stateBlackjackSettings + stateCheckersSettings + statePlaying +) + +// stakeSettings — экран настройки ставки перед игрой: значения +// перебираются по пресетам (а не вводятся текстом), чтобы обойтись +// без отдельного text-input компонента в TUI. +type stakeSettings struct { + capitalOptions []int + anteOptions []int + multiplierOptions []int + + capitalIdx int + anteIdx int + multiplierIdx int + + fieldCursor int // 0 = капитал, 1 = анте, 2 = множитель + gameIdx int // на какую игру из gameRegistry применится +} + +func newStakeSettings(gameIdx int) stakeSettings { + return stakeSettings{ + capitalOptions: []int{50, 100, 200, 500, 1000}, + anteOptions: []int{1, 5, 10, 20, 50}, + multiplierOptions: []int{2, 3, 4}, + capitalIdx: 1, // 100 + anteIdx: 1, // 5 + multiplierIdx: 0, // 2 + gameIdx: gameIdx, + } +} + +func (s stakeSettings) capital() int { return s.capitalOptions[s.capitalIdx] } +func (s stakeSettings) ante() int { return s.anteOptions[s.anteIdx] } +func (s stakeSettings) multiplier() int { return s.multiplierOptions[s.multiplierIdx] } + +// cycle сдвигает значение текущего выбранного поля на delta шагов +// (±1), с ограничением по краям списка пресетов (без зацикливания). +func (s *stakeSettings) cycle(delta int) { + clamp := func(idx, max int) int { + idx += delta + if idx < 0 { + return 0 + } + if idx > max { + return max + } + return idx + } + switch s.fieldCursor { + case 0: + s.capitalIdx = clamp(s.capitalIdx, len(s.capitalOptions)-1) + case 1: + s.anteIdx = clamp(s.anteIdx, len(s.anteOptions)-1) + case 2: + s.multiplierIdx = clamp(s.multiplierIdx, len(s.multiplierOptions)-1) + } +} + +// playerCountSettings — экран выбора количества игроков (2-6, +// включая человека) перед игрой вроде Дурака. +type playerCountSettings struct { + count int + gameIdx int +} + +func newPlayerCountSettings(gameIdx int) playerCountSettings { + return playerCountSettings{count: 4, gameIdx: gameIdx} +} + +func (s *playerCountSettings) cycle(delta int) { + s.count += delta + if s.count < 2 { + s.count = 2 + } + if s.count > 6 { + s.count = 6 + } +} + +// blackjackSettings — совмещённый экран настройки перед Блэкджеком: +// число игроков (1-6, включая человека — можно играть и в одиночку +// против дилера), стартовый капитал и размер ставки за раунд. +// checkersSettings — экран выбора уровня сложности бота перед +// партией в шашки: два уровня, разница только в глубине поиска. +type checkersSettings struct { + levelIdx int + gameIdx int +} + +var checkersLevels = []struct { + Label string + Depth int + Desc string +}{ + {Label: "Простой", Depth: CheckersDifficultyEasy, Desc: "быстрый, ощутимо слабее"}, + {Label: "Сильный", Depth: CheckersDifficultyHard, Desc: "заметно сильнее, чуть медленнее думает"}, +} + +func newCheckersSettings(gameIdx int) checkersSettings { + return checkersSettings{levelIdx: 0, gameIdx: gameIdx} +} + +func (s *checkersSettings) cycle(delta int) { + s.levelIdx += delta + if s.levelIdx < 0 { + s.levelIdx = 0 + } + if s.levelIdx > len(checkersLevels)-1 { + s.levelIdx = len(checkersLevels) - 1 + } +} + +type blackjackSettings struct { + capitalOptions []int + betOptions []int + + playerCount int + capitalIdx int + betIdx int + fieldCursor int // 0 = число игроков, 1 = капитал, 2 = ставка + gameIdx int +} + +func newBlackjackSettings(gameIdx int) blackjackSettings { + return blackjackSettings{ + capitalOptions: []int{50, 100, 200, 500, 1000}, + betOptions: []int{5, 10, 25, 50, 100}, + playerCount: 1, + capitalIdx: 1, // 100 + betIdx: 1, // 10 + gameIdx: gameIdx, + } +} + +func (s blackjackSettings) capital() int { return s.capitalOptions[s.capitalIdx] } +func (s blackjackSettings) bet() int { return s.betOptions[s.betIdx] } + +func (s *blackjackSettings) cycle(delta int) { + clamp := func(idx, max int) int { + idx += delta + if idx < 0 { + return 0 + } + if idx > max { + return max + } + return idx + } + switch s.fieldCursor { + case 0: + s.playerCount += delta + if s.playerCount < 1 { + s.playerCount = 1 + } + if s.playerCount > 6 { + s.playerCount = 6 + } + case 1: + s.capitalIdx = clamp(s.capitalIdx, len(s.capitalOptions)-1) + case 2: + s.betIdx = clamp(s.betIdx, len(s.betOptions)-1) + } +} + +var ( + menuTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) + menuSubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) + menuItemStyle = lipgloss.NewStyle() + menuCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("228")).Bold(true) + menuDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + menuHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Faint(true) +) + +// menuOption — один пункт меню: либо запуск игры из реестра, либо +// служебное действие (правила/выход). +type menuOption struct { + label string + desc string + kind string // "game", "rules", "quit" + gameIdx int // индекс в gameRegistry, если kind == "game" +} + +// AppModel — корневая модель приложения: меню, экран правил или +// запущенная игра. +type AppModel struct { + state appState + cursor int + rulesCursor int + options []menuOption + rulesText string + settings stakeSettings + playerCount playerCountSettings + blackjack blackjackSettings + checkers checkersSettings + activeGame tea.Model + quitting bool +} + +func NewAppModel() AppModel { + options := make([]menuOption, 0, len(gameRegistry)+2) + for i, g := range gameRegistry { + options = append(options, menuOption{label: g.Name, desc: g.Description, kind: "game", gameIdx: i}) + } + options = append(options, menuOption{label: "Правила", desc: "Прочитать правила выбранной игры", kind: "rules"}) + options = append(options, menuOption{label: "Выход", desc: "Закрыть коллекцию игр", kind: "quit"}) + + return AppModel{ + state: stateMenu, + options: options, + } +} + +func (m AppModel) Init() tea.Cmd { + return nil +} + +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.state { + case statePlaying: + return m.updatePlaying(msg) + case stateRulesMenu: + return m.updateRulesMenu(msg) + case stateRules: + return m.updateRules(msg) + case stateSettings: + return m.updateSettings(msg) + case statePlayerCount: + return m.updatePlayerCount(msg) + case stateBlackjackSettings: + return m.updateBlackjackSettings(msg) + case stateCheckersSettings: + return m.updateCheckersSettings(msg) + default: + return m.updateMenu(msg) + } +} + +func (m AppModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + selected := m.options[m.cursor] + switch selected.kind { + case "game": + entry := gameRegistry[selected.gameIdx] + switch { + case entry.UsesStakes: + m.settings = newStakeSettings(selected.gameIdx) + m.state = stateSettings + return m, nil + case entry.UsesPlayerCount: + m.playerCount = newPlayerCountSettings(selected.gameIdx) + m.state = statePlayerCount + return m, nil + case entry.UsesBlackjackSettings: + m.blackjack = newBlackjackSettings(selected.gameIdx) + m.state = stateBlackjackSettings + return m, nil + case entry.UsesCheckersSettings: + m.checkers = newCheckersSettings(selected.gameIdx) + m.state = stateCheckersSettings + return m, nil + } + m.state = statePlaying + m.activeGame = entry.New() + return m, m.activeGame.Init() + case "rules": + m.rulesCursor = 0 + m.state = stateRulesMenu + case "quit": + m.quitting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m AppModel) updateRulesMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateMenu + case "up", "k": + if m.rulesCursor > 0 { + m.rulesCursor-- + } + case "down", "j": + if m.rulesCursor < len(gameRegistry)-1 { + m.rulesCursor++ + } + case "enter": + m.rulesText = gameRegistry[m.rulesCursor].Rules + m.state = stateRules + } + return m, nil +} + +func (m AppModel) updateRules(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "q", "esc", "enter", "ctrl+c": + m.state = stateRulesMenu + } + } + return m, nil +} + +func (m AppModel) updatePlayerCount(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateMenu + return m, nil + case "left", "h", "down", "j": + m.playerCount.cycle(-1) + case "right", "l", "up", "k": + m.playerCount.cycle(1) + case "enter": + entry := gameRegistry[m.playerCount.gameIdx] + m.activeGame = entry.NewWithPlayerCount(m.playerCount.count) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updateBlackjackSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateMenu + return m, nil + case "up", "k": + if m.blackjack.fieldCursor > 0 { + m.blackjack.fieldCursor-- + } + case "down", "j": + if m.blackjack.fieldCursor < 2 { + m.blackjack.fieldCursor++ + } + case "left", "h": + m.blackjack.cycle(-1) + case "right", "l": + m.blackjack.cycle(1) + case "enter": + entry := gameRegistry[m.blackjack.gameIdx] + m.activeGame = entry.NewBlackjackWithSettings(m.blackjack.playerCount, m.blackjack.capital(), m.blackjack.bet()) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updateCheckersSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateMenu + return m, nil + case "up", "k", "left", "h": + m.checkers.cycle(-1) + case "down", "j", "right", "l": + m.checkers.cycle(1) + case "enter": + entry := gameRegistry[m.checkers.gameIdx] + depth := checkersLevels[m.checkers.levelIdx].Depth + m.activeGame = entry.NewCheckersWithDifficulty(depth) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateMenu + return m, nil + case "up", "k": + if m.settings.fieldCursor > 0 { + m.settings.fieldCursor-- + } + case "down", "j": + if m.settings.fieldCursor < 2 { + m.settings.fieldCursor++ + } + case "left", "h": + m.settings.cycle(-1) + case "right", "l": + m.settings.cycle(1) + case "enter": + entry := gameRegistry[m.settings.gameIdx] + m.activeGame = entry.NewWithStakes(m.settings.capital(), m.settings.ante(), m.settings.multiplier()) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { + // Ctrl+C всегда выходит из всего приложения целиком, а не + // только из текущей игры. + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + updated, cmd := m.activeGame.Update(msg) + m.activeGame = updated + + backToMenu := false + switch tm := updated.(type) { + case Model: + backToMenu = tm.backToMenu + case DurakModel: + backToMenu = tm.backToMenu + case BlackjackModel: + backToMenu = tm.backToMenu + case OneOhOneModel: + backToMenu = tm.backToMenu + case ThousandModel: + backToMenu = tm.backToMenu + case KlondikeModel: + backToMenu = tm.backToMenu + case CheckersModel: + backToMenu = tm.backToMenu + } + if backToMenu { + m.state = stateMenu + m.activeGame = nil + return m, nil + } + return m, cmd +} + +func (m AppModel) View() string { + if m.quitting { + return "До встречи!\n" + } + switch m.state { + case statePlaying: + return m.activeGame.View() + case stateRulesMenu: + return m.viewRulesMenu() + case stateRules: + return m.viewRules() + case stateSettings: + return m.viewSettings() + case statePlayerCount: + return m.viewPlayerCount() + case stateBlackjackSettings: + return m.viewBlackjackSettings() + case stateCheckersSettings: + return m.viewCheckersSettings() + default: + return m.viewMenu() + } +} + +func (m AppModel) viewMenu() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(asciiBanner)) + fmt.Fprintln(&b, menuSubtitleStyle.Render("Коллекция консольных игр на Go")) + fmt.Fprintln(&b) + + for i, opt := range m.options { + cursor := " " + label := menuItemStyle.Render(opt.label) + if i == m.cursor { + cursor = menuCursorStyle.Render("> ") + label = menuCursorStyle.Render(opt.label) + } + fmt.Fprintf(&b, "%s%s\n", cursor, label) + if opt.desc != "" { + fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(opt.desc)) + } + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] подтвердить [q] выход")) + return b.String() +} + +func (m AppModel) viewRulesMenu() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА: ВЫБЕРИТЕ ИГРУ ===")) + fmt.Fprintln(&b) + for i, entry := range gameRegistry { + cursor := " " + label := menuItemStyle.Render(entry.Name) + if i == m.rulesCursor { + cursor = menuCursorStyle.Render("> ") + label = menuCursorStyle.Render(entry.Name) + } + fmt.Fprintf(&b, "%s%s\n", cursor, label) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] показать правила [esc/q] назад в меню")) + return b.String() +} + +func (m AppModel) viewRules() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА ===")) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.rulesText) + fmt.Fprintln(&b, menuHintStyle.Render("[enter/esc/q] назад к списку игр")) + return b.String() +} + +func (m AppModel) viewSettings() string { + var b strings.Builder + entry := gameRegistry[m.settings.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА СТАВКИ: %s ===", entry.Name))) + fmt.Fprintln(&b) + + fields := []struct { + label string + value string + }{ + {"Стартовый капитал", fmt.Sprintf("%d", m.settings.capital())}, + {"Ставка (анте) за раунд", fmt.Sprintf("%d", m.settings.ante())}, + {"Множитель банка при Тонке", fmt.Sprintf("×%d", m.settings.multiplier())}, + } + for i, f := range fields { + cursor := " " + line := fmt.Sprintf("%-28s ← %s →", f.label, f.value) + if i == m.settings.fieldCursor { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + pot := m.settings.ante() * 4 + fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf( + "Банк на 4 игроков: %d. Победа Тонком: ×%d = %d.", + pot, m.settings.multiplier(), pot*m.settings.multiplier()))) + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад")) + return b.String() +} + +func (m AppModel) viewPlayerCount() string { + var b strings.Builder + entry := gameRegistry[m.playerCount.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== ЧИСЛО ИГРОКОВ: %s ===", entry.Name))) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "%s\n", menuCursorStyle.Render(fmt.Sprintf(" ← %d игроков (включая вас) →", m.playerCount.count))) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами.", m.playerCount.count-1))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[←→/hl] изменить число игроков (2-6) [enter] начать игру [esc/q] назад")) + return b.String() +} + +func (m AppModel) viewBlackjackSettings() string { + var b strings.Builder + entry := gameRegistry[m.blackjack.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА: %s ===", entry.Name))) + fmt.Fprintln(&b) + + fields := []struct { + label string + value string + }{ + {"Число игроков (включая вас)", fmt.Sprintf("%d", m.blackjack.playerCount)}, + {"Стартовый капитал", fmt.Sprintf("%d", m.blackjack.capital())}, + {"Ставка за раунд", fmt.Sprintf("%d", m.blackjack.bet())}, + } + for i, f := range fields { + cursor := " " + line := fmt.Sprintf("%-30s ← %s →", f.label, f.value) + if i == m.blackjack.fieldCursor { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + botsCount := m.blackjack.playerCount - 1 + if botsCount > 0 { + fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами, каждый играет против дилера отдельно.", botsCount))) + } else { + fmt.Fprintln(&b, menuDescStyle.Render("Вы играете в одиночку против дилера.")) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад")) + return b.String() +} + +func (m AppModel) viewCheckersSettings() string { + var b strings.Builder + entry := gameRegistry[m.checkers.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== УРОВЕНЬ СЛОЖНОСТИ: %s ===", entry.Name))) + fmt.Fprintln(&b) + + for i, lvl := range checkersLevels { + cursor := " " + line := fmt.Sprintf("%s (%s)", lvl.Label, lvl.Desc) + if i == m.checkers.levelIdx { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/←→] выбор уровня [enter] начать игру [esc/q] назад")) + return b.String() +} diff --git a/menu_test.go b/menu_test.go new file mode 100644 index 0000000..1be18e9 --- /dev/null +++ b/menu_test.go @@ -0,0 +1,748 @@ +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestMenuNavigation(t *testing.T) { + m := NewAppModel() + if m.state != stateMenu { + t.Fatalf("ожидалось начальное состояние stateMenu") + } + if len(m.options) != len(gameRegistry)+2 { + t.Fatalf("ожидалось %d пунктов меню, получено %d", len(gameRegistry)+2, len(m.options)) + } + + next, _ := m.Update(key("down")) + nm := next.(AppModel) + if nm.cursor != 1 { + t.Fatalf("ожидался курсор на позиции 1, получено %d", nm.cursor) + } + + next, _ = nm.Update(key("up")) + nm = next.(AppModel) + if nm.cursor != 0 { + t.Fatalf("ожидался курсор на позиции 0 после возврата, получено %d", nm.cursor) + } +} + +func TestMenuLeadsToStakeSettings(t *testing.T) { + m := NewAppModel() + // первый пункт меню — первая (и пока единственная) игра, которая + // использует ставки -> должен открыться экран настроек, а не игра + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateSettings { + t.Fatalf("ожидалось состояние stateSettings после выбора игры со ставками") + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения настроек") + } +} + +func TestStakeSettingsAdjustAndStart(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("enter")) // входим в настройки + m = next.(AppModel) + + initialCapital := m.settings.capital() + next, _ = m.Update(key("right")) // увеличиваем капитал на шаг пресета + m = next.(AppModel) + if m.settings.capital() == initialCapital { + t.Fatalf("значение капитала должно было измениться после [right]") + } + + next, _ = m.Update(key("down")) // переходим к полю анте + m = next.(AppModel) + if m.settings.fieldCursor != 1 { + t.Fatalf("ожидался курсор на поле анте (1), получено %d", m.settings.fieldCursor) + } + + chosenCapital := m.settings.capital() + chosenAnte := m.settings.ante() + chosenMult := m.settings.multiplier() + + next, _ = m.Update(key("enter")) // подтверждаем и запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось состояние statePlaying после подтверждения настроек") + } + tm, ok := m.activeGame.(Model) + if !ok { + t.Fatalf("активная игра должна быть моделью Тонка") + } + if tm.game.StartingCapital != chosenCapital { + t.Errorf("стартовый капитал в игре (%d) не совпадает с выбранным (%d)", + tm.game.StartingCapital, chosenCapital) + } + if tm.game.AnteAmount != chosenAnte { + t.Errorf("анте в игре (%d) не совпадает с выбранным (%d)", tm.game.AnteAmount, chosenAnte) + } + if tm.game.TonkMultiplier != chosenMult { + t.Errorf("множитель в игре (%d) не совпадает с выбранным (%d)", tm.game.TonkMultiplier, chosenMult) + } +} + +func TestStakeSettingsEscReturnsToMenu(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateSettings { + t.Fatalf("ожидался экран настроек") + } + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране настроек должен вернуть в меню") + } +} + +func TestStakeSettingsCycleClampsAtBounds(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("enter")) + m = next.(AppModel) + + // уводим значение влево много раз — не должно уйти за границу + // списка пресетов (индекс не может стать отрицательным) + for i := 0; i < 10; i++ { + next, _ = m.Update(key("left")) + m = next.(AppModel) + } + if m.settings.capitalIdx != 0 { + t.Errorf("индекс капитала должен был остановиться на 0, получено %d", m.settings.capitalIdx) + } +} + +func TestMenuShowsRules(t *testing.T) { + m := NewAppModel() + // переходим к пункту "Правила" (сразу после списка игр) + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRulesMenu { + t.Fatalf("ожидалось состояние stateRulesMenu (подменю выбора игры)") + } + + // в подменю правил курсор по умолчанию на первой игре (Тонк) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ТОНК") { + t.Fatalf("текст правил должен содержать описание Тонка") + } + + // esc из текста правил возвращает в подменю выбора игры, а не сразу в меню + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateRulesMenu { + t.Fatalf("ожидалось возвращение в stateRulesMenu после esc из текста правил") + } + + // esc из подменю возвращает в главное меню + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("ожидалось возвращение в stateMenu после esc из подменю правил") + } +} + +func TestMenuShowsDurakRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + next, _ = m.Update(key("down")) // выбираем вторую игру (Дурак) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ДУРАК") { + t.Fatalf("текст правил должен содержать описание Дурака, получено начало: %.50s", m.rulesText) + } +} + +func TestMenuLeadsToPlayerCountForDurak(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) // второй пункт меню — Дурак + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlayerCount { + t.Fatalf("ожидалось состояние statePlayerCount после выбора Дурака") + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения числа игроков") + } +} + +func TestPlayerCountAdjustAndStart(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) // экран выбора числа игроков + m = next.(AppModel) + + if m.playerCount.count != 4 { + t.Fatalf("ожидалось значение по умолчанию 4, получено %d", m.playerCount.count) + } + next, _ = m.Update(key("right")) + m = next.(AppModel) + if m.playerCount.count != 5 { + t.Fatalf("ожидалось 5 после [right], получено %d", m.playerCount.count) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения числа игроков") + } + dm, ok := m.activeGame.(DurakModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Дурака") + } + if len(dm.game.Players) != 5 { + t.Errorf("ожидалось 5 игроков в запущенной партии, получено %d", len(dm.game.Players)) + } +} + +func TestPlayerCountClampsAtBounds(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + + for i := 0; i < 10; i++ { + next, _ = m.Update(key("left")) + m = next.(AppModel) + } + if m.playerCount.count != 2 { + t.Errorf("число игроков должно было остановиться на 2, получено %d", m.playerCount.count) + } + for i := 0; i < 10; i++ { + next, _ = m.Update(key("right")) + m = next.(AppModel) + } + if m.playerCount.count != 6 { + t.Errorf("число игроков должно было остановиться на 6, получено %d", m.playerCount.count) + } +} + +func TestPlayerCountEscReturnsToMenu(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране выбора числа игроков должен вернуть в меню") + } +} + +func TestDurakQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) // экран числа игроков + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем Дурака + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsBlackjackRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Блэкджек — третья запись в реестре (индекс 2) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "БЛЭКДЖЕК") { + t.Fatalf("текст правил должен содержать описание Блэкджека, получено начало: %.50s", m.rulesText) + } +} + +func TestMenuLeadsToBlackjackSettings(t *testing.T) { + m := NewAppModel() + // Блэкджек — третий пункт меню (после Тонка и Дурака) + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateBlackjackSettings { + t.Fatalf("ожидалось состояние stateBlackjackSettings после выбора Блэкджека") + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения настроек") + } +} + +func TestBlackjackSettingsAdjustAndStart(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) // экран настроек + m = next.(AppModel) + + if m.blackjack.playerCount != 1 { + t.Fatalf("ожидалось значение по умолчанию 1 игрок, получено %d", m.blackjack.playerCount) + } + next, _ = m.Update(key("right")) + m = next.(AppModel) + if m.blackjack.playerCount != 2 { + t.Fatalf("ожидалось 2 после [right], получено %d", m.blackjack.playerCount) + } + + next, _ = m.Update(key("down")) // поле капитала + m = next.(AppModel) + if m.blackjack.fieldCursor != 1 { + t.Fatalf("ожидался курсор на поле капитала (1), получено %d", m.blackjack.fieldCursor) + } + chosenPlayers := m.blackjack.playerCount + chosenCapital := m.blackjack.capital() + chosenBet := m.blackjack.bet() + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения настроек") + } + bm, ok := m.activeGame.(BlackjackModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Блэкджека") + } + if len(bm.game.Players) != chosenPlayers { + t.Errorf("число игроков в игре (%d) не совпадает с выбранным (%d)", len(bm.game.Players), chosenPlayers) + } + if bm.game.StartingCapital != chosenCapital { + t.Errorf("капитал в игре (%d) не совпадает с выбранным (%d)", bm.game.StartingCapital, chosenCapital) + } + if bm.game.Bet != chosenBet { + t.Errorf("ставка в игре (%d) не совпадает с выбранной (%d)", bm.game.Bet, chosenBet) + } +} + +func TestBlackjackSettingsEscReturnsToMenu(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране настроек Блэкджека должен вернуть в меню") + } +} + +func TestBlackjackQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("down")) + m = next.(AppModel) + next, _ = m.Update(key("enter")) // настройки + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsOneOhOneRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // 101 — четвёртая запись в реестре (индекс 3) + for i := 0; i < 3; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "101") { + t.Fatalf("текст правил должен содержать описание 101, получено начало: %.50s", m.rulesText) + } +} + +func TestMenuLeadsToPlayerCountForOneOhOne(t *testing.T) { + m := NewAppModel() + // 101 — четвёртый пункт меню + for i := 0; i < 3; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlayerCount { + t.Fatalf("ожидалось состояние statePlayerCount после выбора 101") + } +} + +func TestOneOhOneQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + for i := 0; i < 3; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран числа игроков + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsThousandRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // 1000 — пятая запись в реестре (индекс 4) + for i := 0; i < 4; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ТЫСЯЧА") { + t.Fatalf("текст правил должен содержать описание Тысячи, получено начало: %.50s", m.rulesText) + } +} + +func TestThousandStartsDirectlyWithoutSettingsScreen(t *testing.T) { + m := NewAppModel() + // 1000 — пятый пункт меню, запускается сразу (без экрана настроек) + for i := 0; i < 4; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось немедленное statePlaying для 1000, получено %v", m.state) + } + if _, ok := m.activeGame.(ThousandModel); !ok { + t.Fatalf("активная игра должна быть моделью 1000") + } +} + +func TestThousandQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + for i := 0; i < 4; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsKlondikeRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Косынка — шестая запись в реестре (индекс 5) + for i := 0; i < 5; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "КОСЫНКА") { + t.Fatalf("текст правил должен содержать описание Косынки, получено начало: %.50s", m.rulesText) + } +} + +func TestKlondikeStartsDirectlyWithoutSettingsScreen(t *testing.T) { + m := NewAppModel() + for i := 0; i < 5; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось немедленное statePlaying для Косынки, получено %v", m.state) + } + if _, ok := m.activeGame.(KlondikeModel); !ok { + t.Fatalf("активная игра должна быть моделью Косынки") + } +} + +func TestKlondikeQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + for i := 0; i < 5; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsCheckersRules(t *testing.T) { + m := NewAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Шашки — седьмая запись в реестре (индекс 6) + for i := 0; i < 6; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ШАШКИ") { + t.Fatalf("текст правил должен содержать описание Шашек, получено начало: %.50s", m.rulesText) + } +} + +func TestCheckersLeadsToDifficultySettings(t *testing.T) { + m := NewAppModel() + for i := 0; i < 6; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateCheckersSettings { + t.Fatalf("ожидалось состояние stateCheckersSettings для Шашек, получено %v", m.state) + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения уровня сложности") + } +} + +func TestCheckersDifficultySelectionAndStart(t *testing.T) { + m := NewAppModel() + for i := 0; i < 6; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран выбора сложности + m = next.(AppModel) + if m.checkers.levelIdx != 0 { + t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.checkers.levelIdx) + } + + next, _ = m.Update(key("down")) // переключаемся на "Сильный" + m = next.(AppModel) + if m.checkers.levelIdx != 1 { + t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.checkers.levelIdx) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения уровня") + } + cm, ok := m.activeGame.(CheckersModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Шашек") + } + if cm.bot.Depth != CheckersDifficultyHard { + t.Errorf("ожидалась глубина бота %d (Сильный), получено %d", CheckersDifficultyHard, cm.bot.Depth) + } +} + +func TestCheckersDifficultyEscReturnsToMenu(t *testing.T) { + m := NewAppModel() + for i := 0; i < 6; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране сложности должен вернуть в меню") + } +} + +func TestCheckersQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + for i := 0; i < 6; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран сложности + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuQuit(t *testing.T) { + m := NewAppModel() + // последний пункт меню — "Выход" + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, cmd := m.Update(key("enter")) + m = next.(AppModel) + if !m.quitting { + t.Fatalf("ожидался флаг quitting после выбора пункта 'Выход'") + } + if cmd == nil { + t.Fatalf("ожидалась команда tea.Quit") + } +} + +func TestGameQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("enter")) // открываем настройки ставки + m = next.(AppModel) + next, _ = m.Update(key("enter")) // подтверждаем настройки и запускаем Тонк + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, cmd := m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню, а не закрыть приложение") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться при возврате в меню") + } + if cmd != nil { + // допустима nil-команда — важно лишь, что это не tea.Quit + _ = cmd + } +} + +func TestAppModelImplementsTeaModel(t *testing.T) { + var _ tea.Model = AppModel{} +} diff --git a/oneohone_bot.go b/oneohone_bot.go new file mode 100644 index 0000000..0b2d920 --- /dev/null +++ b/oneohone_bot.go @@ -0,0 +1,102 @@ +package main + +import "math/rand" + +// oneOhOneNamePool — имена ботов в 101. Отдельный пул от других игр +// коллекции — обычные русские женские имена для разнообразия. +var oneOhOneNamePool = []string{ + "Мария", "Анна", "Ольга", "Елена", "Наталья", "Светлана", "Ирина", "Татьяна", +} + +func pickOneOhOneBotName(used map[string]bool) string { + free := make([]string, 0, len(oneOhOneNamePool)) + for _, name := range oneOhOneNamePool { + if !used[name] { + free = append(free, name) + } + } + if len(free) == 0 { + free = oneOhOneNamePool + } + return free[rand.Intn(len(free))] +} + +func newOneOhOneBotNames(n int) []string { + used := make(map[string]bool, n) + names := make([]string, n) + for i := 0; i < n; i++ { + name := pickOneOhOneBotName(used) + used[name] = true + names[i] = name + } + return names +} + +// OneOhOneBot — единственная (без градации уровней) стратегия бота +// для 101: старается в первую очередь избавляться от специальных и +// дорогих карт, чтобы и снизить будущий штраф, и потрепать +// соперников. +type OneOhOneBot struct{} + +// DecideAction решает, что делать текущему игроку: если есть +// подходящая карта — возвращает (card, true) с наиболее выгодной +// для игры картой; если подходящей карты нет — возвращает +// (Card{}, false), и вызывающий код должен вызвать g.Draw(). +func (OneOhOneBot) DecideAction(g *OneOhOneGameState) (Card, bool) { + p := g.current() + var candidates []Card + for _, card := range p.Hand { + if g.canPlay(card) { + candidates = append(candidates, card) + } + } + if len(candidates) == 0 { + return Card{}, false + } + + best := candidates[0] + bestScore := oneOhOnePlayPriority(best) + for _, c := range candidates[1:] { + if s := oneOhOnePlayPriority(c); s > bestScore { + best = c + bestScore = s + } + } + return best, true +} + +// oneOhOnePlayPriority оценивает, насколько выгодно сыграть карту +// прямо сейчас: специальные карты (создающие проблемы сопернику) +// ценятся выше, среди обычных карт предпочтение отдаётся более +// дорогим (чтобы не осталась в руке при чужой победе). +func oneOhOnePlayPriority(c Card) int { + skip, penalty := oneOhOneCardEffect(c) + if skip { + return 100 + penalty*10 + } + if c.Rank == Queen { + return 90 // дама — хороший финальный аккорд, но и просто ценна для сброса + } + return oneOhOneCardValue(c.Rank) +} + +// PlayFullTurn исполняет один полный ход бота (в контексте 101 — +// одно решение: сыграть карту или взять из колоды, повторяя Draw +// при необходимости до тех пор, пока подходящая карта не найдётся +// или колода не иссякнет) — используется при автоматической +// прокрутке партии. +func (b OneOhOneBot) PlayFullTurn(g *OneOhOneGameState) error { + if g.Phase != OneOhOnePhasePlay { + return nil + } + turnPlayer := g.CurrentPlayerIdx + for g.Phase == OneOhOnePhasePlay && g.CurrentPlayerIdx == turnPlayer { + if card, ok := b.DecideAction(g); ok { + return g.PlayCard(card) + } + if err := g.Draw(); err != nil { + return err + } + } + return nil +} diff --git a/oneohone_bot_test.go b/oneohone_bot_test.go new file mode 100644 index 0000000..8716c96 --- /dev/null +++ b/oneohone_bot_test.go @@ -0,0 +1,80 @@ +package main + +import "testing" + +// TestOneOhOneBotFullMatches прогоняет много полных матчей (до +// одного оставшегося игрока) силами OneOhOneBot для 2-6 игроков, +// проверяя отсутствие паник, зависаний и потери/дублирования карт. +func TestOneOhOneBotFullMatches(t *testing.T) { + bot := OneOhOneBot{} + + for numPlayers := 2; numPlayers <= 6; numPlayers++ { + for match := 0; match < 15; match++ { + names := make([]string, numPlayers) + for i := range names { + names[i] = "Bot" + } + g := NewOneOhOneGame(names, -1) + + const maxRounds = 200 + round := 0 + for ; round < maxRounds; round++ { + const maxSteps = 6000 + steps := 0 + for ; steps < maxSteps && g.Phase != OneOhOnePhaseRoundOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("игроков %d, матч %d, раунд %d, шаг %d: ошибка бота: %v", + numPlayers, match, round, steps, err) + } + } + if steps >= maxSteps { + t.Fatalf("игроков %d, матч %d, раунд %d: раунд не завершился за %d шагов", + numPlayers, match, round, maxSteps) + } + if g.Result == nil { + t.Fatalf("игроков %d, матч %d, раунд %d: раунд завершился без результата", + numPlayers, match, round) + } + + // проверка сохранности карт: без дублей и потерь + seen := map[Card]bool{} + for _, p := range g.Players { + for _, c := range p.Hand { + if seen[c] { + t.Fatalf("игроков %d, матч %d, раунд %d: карта %v встретилась дважды", + numPlayers, match, round, c) + } + seen[c] = true + } + } + for _, c := range g.Stock { + if seen[c] { + t.Fatalf("игроков %d, матч %d, раунд %d: карта %v встретилась дважды (в колоде)", + numPlayers, match, round, c) + } + seen[c] = true + } + for _, c := range g.Discard { + if seen[c] { + t.Fatalf("игроков %d, матч %d, раунд %d: карта %v встретилась дважды (в сбросе)", + numPlayers, match, round, c) + } + seen[c] = true + } + if len(seen) != 36 { + t.Fatalf("игроков %d, матч %d, раунд %d: ожидалось 36 карт в игре, получено %d", + numPlayers, match, round, len(seen)) + } + + if g.Result.MatchOver { + break + } + g.NextRound() + } + if round >= maxRounds { + t.Fatalf("игроков %d, матч %d: матч не завершился за %d раундов (возможное зависание)", + numPlayers, match, maxRounds) + } + } + } +} diff --git a/oneohone_game.go b/oneohone_game.go new file mode 100644 index 0000000..f72f3b0 --- /dev/null +++ b/oneohone_game.go @@ -0,0 +1,435 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: у игры "101" существует много вариантов, +// расходящихся в деталях. Реализован следующий, наиболее часто +// встречающийся набор правил: +// - Колода 36 карт (6-туз), как и в Дураке. +// - Раздача: сдающий раздаёт по 5 карт каждому игроку, кроме +// себя — себе он оставляет 4, а пятую (лишнюю) кладёт в центр +// стола лицом вверх — это и есть стартовая карта сброса. +// - Ход: нужно положить карту той же масти или того же ранга, +// что и верхняя карта сброса. Если подходящей карты нет — +// игрок обязан брать карты из колоды добора по одной, пока не +// возьмёт подходящую (тогда сразу её играет) либо пока колода +// не закончится (тогда ход просто переходит дальше). +// - Специальные карты: туз пропускает ход следующего игрока; +// шестёрка заставляет следующего взять 1 карту и пропустить +// ход; семёрка — взять 2 карты и пропустить ход; пиковый +// король — взять 4 карты и пропустить ход. Остальные короли и +// карты обычные. +// - Игрок, избавившийся от всех карт, выигрывает раунд. Если его +// последней сыгранной картой была дама — он получает бонус +// -20 очков за раунд (-40, если это была червовая дама) вместо +// обычных 0; остальные складывают очковую стоимость карт, +// оставшихся на руках, и добавляют к своему общему счёту. +// - Набравший 101 очко и более выбывает из партии, если только +// это не ровно 101 — тогда счёт обнуляется (традиционное +// правило "отыграться"). Партия продолжается, пока не останется +// один игрок — он и побеждает. + +// OneOhOnePlayer — состояние одного игрока в партии 101. +type OneOhOnePlayer struct { + Name string + Hand []Card + IsHuman bool + Score int // суммарный счёт по итогам сыгранных раундов + Out bool +} + +// OneOhOnePhase — фаза текущего раунда. +type OneOhOnePhase int + +const ( + // OneOhOnePhasePlay — обычный ход текущего игрока: сыграть карту + // или взять из колоды добора. + OneOhOnePhasePlay OneOhOnePhase = iota + // OneOhOnePhaseRoundOver — раунд завершён. + OneOhOnePhaseRoundOver +) + +var ( + ErrOOOWrongPhase = errors.New("недопустимое действие для текущей фазы") + ErrOOOCardNotInHand = errors.New("карты нет на руке игрока") + ErrOOOInvalidPlay = errors.New("эта карта не подходит по масти или рангу") + ErrOOOMustPlay = errors.New("нужно сыграть карту, которая подходит — брать из колоды нельзя") +) + +// OneOhOneRoundResult — итог одного завершившегося раунда. +type OneOhOneRoundResult struct { + Winner int + QueenBonus bool // победитель закончил дамой (бонус учтён в ScoreDeltas) + ScoreDeltas []int // изменение ОЧКОВ ЗА ЭТОТ РАУНД (не общего счёта) + Eliminated []int // индексы игроков, выбывших в этом раунде (счёт >101) + Reset []int // индексы игроков, у которых счёт обнулился (ровно 101) + MatchOver bool + MatchWinner int // имеет смысл только при MatchOver + NoWinner bool // вырожденный случай: все выбыли одновременно + Stalemate bool // раунд принудительно завершён защитой от зацикливания +} + +// OneOhOneGameState — полное состояние партии в 101. +type OneOhOneGameState struct { + Players []*OneOhOnePlayer + + Stock []Card // колода добора; Stock[0] — верхняя (берётся первой) + Discard []Card // сброс; последний элемент — верхняя (видимая) карта + + CurrentPlayerIdx int + DealerIdx int + Phase OneOhOnePhase + Result *OneOhOneRoundResult + + turnsSinceStart int +} + +// maxOneOhOneTurns — защита от зацикливания: если раунд длится +// неправдоподобно долго, принудительно завершаем его вничью (по +// текущему весу рук), чтобы партия гарантированно не зависла. +const maxOneOhOneTurns = 5000 + +// oneOhOneCardValue — очковая стоимость карты для подсчёта штрафа +// за карты, оставшиеся на руках. +func oneOhOneCardValue(r Rank) int { + switch r { + case Ace: + return 11 + case Ten: + return 10 + case Nine: + return 0 + case Eight: + return 8 + case Seven: + return 7 + case Six: + return 6 + case King: + return 4 + case Queen: + return 3 + case Jack: + return 2 + } + return 0 +} + +// NewOneOhOneGame создаёт новую партию: игрок 0 — первый сдающий. +func NewOneOhOneGame(names []string, humanIdx int) *OneOhOneGameState { + players := make([]*OneOhOnePlayer, len(names)) + for i, name := range names { + players[i] = &OneOhOnePlayer{Name: name, IsHuman: i == humanIdx} + } + g := &OneOhOneGameState{Players: players, DealerIdx: 0} + g.dealNewRound() + return g +} + +// dealNewRound тасует колоду и раздаёт новый раунд: по 5 карт всем +// АКТИВНЫМ (не выбывшим) игрокам, кроме сдающего (ему 4, а "лишняя" +// пятая уходит в сброс как стартовая карта). Первым ходит игрок +// слева от сдающего. +func (g *OneOhOneGameState) dealNewRound() { + deck := NewDurakDeck() + deck.Shuffle() + stock := deck.cards + + for _, p := range g.Players { + p.Hand = nil + } + + active := make([]int, 0, len(g.Players)) + for i, p := range g.Players { + if !p.Out { + active = append(active, i) + } + } + + for _, idx := range active { + count := 5 + if idx == g.DealerIdx { + count = 4 + } + for c := 0; c < count; c++ { + card := stock[0] + stock = stock[1:] + g.Players[idx].Hand = append(g.Players[idx].Hand, card) + } + } + + // "лишняя" пятая карта сдающего уходит в сброс как стартовая + openingCard := stock[0] + stock = stock[1:] + + g.Stock = stock + g.Discard = []Card{openingCard} + g.CurrentPlayerIdx = g.nextActive(g.DealerIdx) + g.Phase = OneOhOnePhasePlay + g.Result = nil + g.turnsSinceStart = 0 +} + +func (g *OneOhOneGameState) nextActive(from int) int { + n := len(g.Players) + for i := 1; i <= n; i++ { + idx := (from + i) % n + if !g.Players[idx].Out { + return idx + } + } + return from +} + +func (g *OneOhOneGameState) current() *OneOhOnePlayer { return g.Players[g.CurrentPlayerIdx] } + +func (g *OneOhOneGameState) topDiscard() Card { + return g.Discard[len(g.Discard)-1] +} + +// canPlay проверяет, подходит ли карта по масти или рангу под +// верхнюю карту сброса. +func (g *OneOhOneGameState) canPlay(card Card) bool { + top := g.topDiscard() + return card.Suit == top.Suit || card.Rank == top.Rank +} + +// hasPlayableCard проверяет, есть ли у игрока хоть одна карта, +// подходящая под текущий сброс. +func (g *OneOhOneGameState) hasPlayableCard(p *OneOhOnePlayer) bool { + for _, c := range p.Hand { + if g.canPlay(c) { + return true + } + } + return false +} + +// drawOne снимает верхнюю карту колоды добора, при необходимости +// сначала перетасовывая сброс (кроме верхней карты) в новую колоду. +func (g *OneOhOneGameState) drawOne() Card { + if len(g.Stock) == 0 { + g.reshuffleStockFromDiscard() + } + if len(g.Stock) == 0 { + return Card{} + } + c := g.Stock[0] + g.Stock = g.Stock[1:] + return c +} + +// stockExhausted проверяет, остались ли вообще карты для добора (в +// колоде или в сбросе под верхней картой). +func (g *OneOhOneGameState) stockExhausted() bool { + return len(g.Stock) == 0 && len(g.Discard) <= 1 +} + +func (g *OneOhOneGameState) reshuffleStockFromDiscard() { + if len(g.Discard) <= 1 { + return + } + top := g.Discard[len(g.Discard)-1] + rest := g.Discard[:len(g.Discard)-1] + newStock := &Deck{cards: append([]Card{}, rest...)} + newStock.Shuffle() + g.Stock = newStock.cards + g.Discard = []Card{top} +} + +// PlayCard — текущий игрок играет карту из руки. +func (g *OneOhOneGameState) PlayCard(card Card) error { + if g.Phase != OneOhOnePhasePlay { + return ErrOOOWrongPhase + } + p := g.current() + if !hasCard(p.Hand, card) { + return ErrOOOCardNotInHand + } + if !g.canPlay(card) { + return ErrOOOInvalidPlay + } + + p.Hand = removeCard(p.Hand, card) + g.Discard = append(g.Discard, card) + + if len(p.Hand) == 0 { + g.resolveRoundEnd(g.CurrentPlayerIdx, card) + return nil + } + + g.applyEffectAndAdvance(card) + return nil +} + +// Draw — текущий игрок берёт карту из колоды добора (разрешено +// только если у него нет ни одной подходящей карты на руке). Если +// взятая карта подходит — она сразу же автоматически играется; +// иначе колода добора снова спрашивается на следующем вызове Draw +// тем же игроком (в TUI/боте это просто повторные нажатия/вызовы). +func (g *OneOhOneGameState) Draw() error { + if g.Phase != OneOhOnePhasePlay { + return ErrOOOWrongPhase + } + p := g.current() + if g.hasPlayableCard(p) { + return ErrOOOMustPlay + } + if g.stockExhausted() { + // брать неоткуда — ход переходит дальше без штрафов + g.advanceTurnPlain() + return nil + } + card := g.drawOne() + p.Hand = append(p.Hand, card) + if g.canPlay(card) { + p.Hand = removeCard(p.Hand, card) + g.Discard = append(g.Discard, card) + if len(p.Hand) == 0 { + g.resolveRoundEnd(g.CurrentPlayerIdx, card) + return nil + } + g.applyEffectAndAdvance(card) + } + return nil +} + +// applyEffectAndAdvance обрабатывает эффект специальной карты (если +// есть) и передаёт ход дальше. +func (g *OneOhOneGameState) applyEffectAndAdvance(played Card) { + g.turnsSinceStart++ + if g.turnsSinceStart >= maxOneOhOneTurns { + g.resolveStalemate() + return + } + + skip, penalty := oneOhOneCardEffect(played) + if !skip { + g.CurrentPlayerIdx = g.nextActive(g.CurrentPlayerIdx) + return + } + + skippedIdx := g.nextActive(g.CurrentPlayerIdx) + skipped := g.Players[skippedIdx] + for i := 0; i < penalty; i++ { + if g.stockExhausted() { + break + } + skipped.Hand = append(skipped.Hand, g.drawOne()) + } + g.CurrentPlayerIdx = g.nextActive(skippedIdx) +} + +func (g *OneOhOneGameState) advanceTurnPlain() { + g.turnsSinceStart++ + if g.turnsSinceStart >= maxOneOhOneTurns { + g.resolveStalemate() + return + } + g.CurrentPlayerIdx = g.nextActive(g.CurrentPlayerIdx) +} + +// oneOhOneCardEffect возвращает, пропускает ли карта следующего +// игрока и сколько штрафных карт он должен взять. +func oneOhOneCardEffect(c Card) (skip bool, penalty int) { + switch { + case c.Rank == Ace: + return true, 0 + case c.Rank == Six: + return true, 1 + case c.Rank == Seven: + return true, 2 + case c.Rank == King && c.Suit == Spades: + return true, 4 + default: + return false, 0 + } +} + +func (g *OneOhOneGameState) resolveStalemate() { + g.Result = &OneOhOneRoundResult{Stalemate: true, ScoreDeltas: make([]int, len(g.Players))} + g.Phase = OneOhOnePhaseRoundOver +} + +// resolveRoundEnd завершает раунд победой игрока winnerIdx: считает +// штрафные очки остальных, бонус за даму, обновляет общий счёт и +// проверяет выбывание/окончание партии. +func (g *OneOhOneGameState) resolveRoundEnd(winnerIdx int, lastCard Card) { + deltas := make([]int, len(g.Players)) + queenBonus := false + + if lastCard.Rank == Queen { + queenBonus = true + if lastCard.Suit == Hearts { + deltas[winnerIdx] = -40 + } else { + deltas[winnerIdx] = -20 + } + } + + for i, p := range g.Players { + if i == winnerIdx || p.Out { + continue + } + sum := 0 + for _, c := range p.Hand { + sum += oneOhOneCardValue(c.Rank) + } + deltas[i] = sum + } + + for i, p := range g.Players { + p.Score += deltas[i] + } + + var eliminated, reset []int + for i, p := range g.Players { + if p.Score == 101 { + p.Score = 0 + reset = append(reset, i) + } else if p.Score > 101 { + p.Out = true + eliminated = append(eliminated, i) + } + } + + result := &OneOhOneRoundResult{ + Winner: winnerIdx, + QueenBonus: queenBonus, + ScoreDeltas: deltas, + Eliminated: eliminated, + Reset: reset, + } + + active := 0 + lastActive := -1 + for i, p := range g.Players { + if !p.Out { + active++ + lastActive = i + } + } + switch active { + case 0: + result.MatchOver = true + result.NoWinner = true + case 1: + result.MatchOver = true + result.MatchWinner = lastActive + } + + g.Result = result + g.Phase = OneOhOnePhaseRoundOver + if !result.MatchOver { + g.DealerIdx = winnerIdx + } +} + +// NextRound начинает новый раунд партии (сдающий — победитель +// предыдущего раунда), сохраняя счёт и статус выбывания игроков. +func (g *OneOhOneGameState) NextRound() { + if g.Result != nil && g.Result.MatchOver { + return + } + g.dealNewRound() +} diff --git a/oneohone_game_test.go b/oneohone_game_test.go new file mode 100644 index 0000000..2fe6f08 --- /dev/null +++ b/oneohone_game_test.go @@ -0,0 +1,352 @@ +package main + +import "testing" + +func TestOneOhOneDealsCorrectly(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + for i, p := range g.Players { + want := 5 + if i == g.DealerIdx { + want = 4 + } + if len(p.Hand) != want { + t.Errorf("игрок %d: ожидалось %d карт, получено %d", i, want, len(p.Hand)) + } + } + if len(g.Discard) != 1 { + t.Fatalf("ожидалась 1 карта в сбросе (стартовая), получено %d", len(g.Discard)) + } + if len(g.Stock) != 36-14-1 { + t.Fatalf("ожидалось %d карт в колоде добора, получено %d", 36-14-1, len(g.Stock)) + } + if g.CurrentPlayerIdx != g.nextActive(g.DealerIdx) { + t.Errorf("первым должен ходить игрок слева от сдающего") + } +} + +func TestPlayCardMatchingSuit(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.topDiscard() != (c(Ten, Clubs)) { + t.Errorf("верхняя карта сброса должна была смениться") + } +} + +func TestPlayCardMatchingRank(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Nine, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Nine, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } +} + +func TestPlayCardRejectsMismatch(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Hearts)); err != ErrOOOInvalidPlay { + t.Errorf("ожидалась ошибка ErrOOOInvalidPlay, получено: %v", err) + } +} + +func TestDrawRejectedWhenPlayableCardExists(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Nine, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.Draw(); err != ErrOOOMustPlay { + t.Errorf("ожидалась ошибка ErrOOOMustPlay, получено: %v", err) + } +} + +func TestDrawAutoPlaysWhenDrawnCardMatches(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Hearts)} // не подходит + g.Stock = []Card{c(Nine, Diamonds), c(Two, Clubs)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.Draw(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.topDiscard() != (c(Nine, Diamonds)) { + t.Errorf("взятая подходящая карта должна была сразу сыграться") + } + if hasCard(g.Players[0].Hand, c(Nine, Diamonds)) { + t.Errorf("сыгранной карты не должно остаться в руке") + } +} + +func TestAceSkipsNextPlayer(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ace, Clubs), c(Two, Diamonds)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.Players[2].Hand = []Card{c(Jack, Spades)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ace, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.CurrentPlayerIdx != 2 { + t.Errorf("туз должен был пропустить игрока 1, ожидался ход игрока 2, получено %d", g.CurrentPlayerIdx) + } + if len(g.Players[1].Hand) != 1 { + t.Errorf("пропущенный игрок не должен был брать штрафные карты") + } +} + +func TestSevenGivesTwoPenaltyCardsAndSkips(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Seven, Clubs), c(Two, Diamonds)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.Players[2].Hand = []Card{c(Jack, Spades)} + g.Stock = []Card{c(Three, Hearts), c(Four, Hearts), c(Five, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Seven, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Players[1].Hand) != 3 { + t.Errorf("игрок 1 должен был взять 2 штрафные карты (1+2=3), получено %d карт", len(g.Players[1].Hand)) + } + if g.CurrentPlayerIdx != 2 { + t.Errorf("ход должен был перейти к игроку 2, получено %d", g.CurrentPlayerIdx) + } +} + +func TestKingOfSpadesGivesFourPenaltyCards(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Spades)} + g.Players[0].Hand = []Card{c(King, Spades), c(Two, Diamonds)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.Stock = make([]Card, 0, 4) + for i := 0; i < 4; i++ { + g.Stock = append(g.Stock, c(Rank(2+i), Hearts)) + } + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(King, Spades)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Players[1].Hand) != 5 { + t.Errorf("игрок 1 должен был взять 4 штрафные карты (1+4=5), получено %d", len(g.Players[1].Hand)) + } +} + +func TestOtherKingsHaveNoSpecialEffect(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + g.Discard = []Card{c(Nine, Hearts)} + g.Players[0].Hand = []Card{c(King, Hearts), c(Two, Diamonds)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.Players[2].Hand = []Card{c(Jack, Spades)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(King, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.CurrentPlayerIdx != 1 { + t.Errorf("некозырной король не должен ничего пропускать, ожидался ход игрока 1, получено %d", g.CurrentPlayerIdx) + } + if len(g.Players[1].Hand) != 1 { + t.Errorf("игрок 1 не должен был брать штрафные карты") + } +} + +func TestRoundEndScoresRemainingCards(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.Players[1].Hand = []Card{c(King, Hearts), c(Ace, Diamonds)} // 4+11=15 + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != OneOhOnePhaseRoundOver { + t.Fatalf("раунд должен был завершиться победой игрока 0") + } + if g.Result.Winner != 0 { + t.Fatalf("ожидался победитель 0, получен %d", g.Result.Winner) + } + if g.Result.ScoreDeltas[0] != 0 { + t.Errorf("победитель без дамы должен получить 0 очков за раунд, получено %d", g.Result.ScoreDeltas[0]) + } + if g.Result.ScoreDeltas[1] != 15 { + t.Errorf("ожидалось 15 штрафных очков у проигравшего, получено %d", g.Result.ScoreDeltas[1]) + } + if g.Players[1].Score != 15 { + t.Errorf("общий счёт игрока 1 должен был стать 15, получено %d", g.Players[1].Score) + } +} + +func TestQueenBonusOnWin(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Queen, Clubs)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Queen, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Result.QueenBonus { + t.Fatalf("ожидался бонус за даму") + } + if g.Result.ScoreDeltas[0] != -20 { + t.Errorf("ожидался бонус -20 за некозырную даму, получено %d", g.Result.ScoreDeltas[0]) + } +} + +func TestQueenOfHeartsGivesDoubleBonus(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Hearts)} + g.Players[0].Hand = []Card{c(Queen, Hearts)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Queen, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result.ScoreDeltas[0] != -40 { + t.Errorf("ожидался бонус -40 за червовую даму, получено %d", g.Result.ScoreDeltas[0]) + } +} + +func TestExactly101ResetsToZero(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Players[1].Score = 90 // 90+11(туз)=101 + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.Players[1].Hand = []Card{c(Ace, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Players[1].Score != 0 { + t.Errorf("ровно 101 должно было обнулить счёт, получено %d", g.Players[1].Score) + } + if len(g.Result.Reset) != 1 || g.Result.Reset[0] != 1 { + t.Errorf("ожидался Reset=[1], получено %v", g.Result.Reset) + } + if g.Players[1].Out { + t.Errorf("игрок с обнулённым счётом не должен выбывать") + } +} + +func TestOver101Eliminates(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + g.Players[1].Score = 95 // 95+11=106 > 101 + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.Players[1].Hand = []Card{c(Ace, Hearts)} + g.Players[2].Hand = []Card{c(Jack, Spades)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Players[1].Out { + t.Errorf("игрок с 106 очками должен был выбыть") + } + if g.Result.MatchOver { + t.Errorf("партия не должна была завершиться (остались 2 активных игрока)") + } +} + +func TestMatchEndsWhenOneActivePlayerRemains(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Players[1].Score = 95 + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.Players[1].Hand = []Card{c(Ace, Hearts)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Result.MatchOver { + t.Fatalf("партия должна была завершиться (остался 1 активный игрок)") + } + if g.Result.MatchWinner != 0 { + t.Errorf("ожидался победитель партии 0, получено %d", g.Result.MatchWinner) + } +} + +func TestNextRoundDealerIsRoundWinner(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B", "C"}, -1) + g.Discard = []Card{c(Nine, Clubs)} + g.Players[0].Hand = []Card{c(Ten, Clubs)} + g.Players[1].Hand = []Card{c(Ten, Hearts)} + g.Players[2].Hand = []Card{c(Jack, Spades)} + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.DealerIdx != 0 { + t.Fatalf("сдающим следующего раунда должен стать победитель (0), получено %d", g.DealerIdx) + } + + g.NextRound() + if g.Phase != OneOhOnePhasePlay { + t.Fatalf("новый раунд должен был начаться") + } + for i, p := range g.Players { + want := 5 + if i == g.DealerIdx { + want = 4 + } + if len(p.Hand) != want { + t.Errorf("игрок %d: ожидалось %d карт в новом раунде, получено %d", i, want, len(p.Hand)) + } + } +} + +func TestStockReshufflesFromDiscardWhenEmpty(t *testing.T) { + g := NewOneOhOneGame([]string{"A", "B"}, -1) + g.Discard = []Card{c(Nine, Clubs), c(Ten, Diamonds), c(Jack, Diamonds)} + g.Stock = nil + g.Players[0].Hand = []Card{c(Eight, Clubs)} // не подходит ни по масти (clubs vs diamonds), ни по рангу (8 vs J) + g.CurrentPlayerIdx = 0 + g.Phase = OneOhOnePhasePlay + + if g.stockExhausted() { + t.Fatalf("колода не должна считаться исчерпанной — под верхней картой сброса есть ещё карты") + } + if err := g.Draw(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if len(g.Discard) > 2 { + t.Errorf("после перетасовки в сбросе не должно накопиться старых карт, получено %d карт", len(g.Discard)) + } +} diff --git a/oneohone_tui.go b/oneohone_tui.go new file mode 100644 index 0000000..2693d62 --- /dev/null +++ b/oneohone_tui.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// OneOhOneModel — TUI поверх OneOhOneGameState. +type OneOhOneModel struct { + game *OneOhOneGameState + humanIdx int + bot OneOhOneBot + + cursor int + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewOneOhOneModel создаёт новую партию 101 на numPlayers игроков +// (2-6, включая человека) со случайными именами ботов. +func NewOneOhOneModel(numPlayers int) OneOhOneModel { + if numPlayers < 2 { + numPlayers = 2 + } + if numPlayers > 6 { + numPlayers = 6 + } + botNames := newOneOhOneBotNames(numPlayers - 1) + names := append([]string{"Вы"}, botNames...) + + return OneOhOneModel{ + game: NewOneOhOneGame(names, 0), + humanIdx: 0, + bot: OneOhOneBot{}, + } +} + +func (m OneOhOneModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +func (m OneOhOneModel) maybeScheduleBot() tea.Cmd { + if m.game.Phase != OneOhOnePhasePlay { + return nil + } + if m.game.CurrentPlayerIdx == m.humanIdx { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *OneOhOneModel) resetSelection() { + hand := m.currentHand() + if len(hand) == 0 { + m.cursor = 0 + } else if m.cursor >= len(hand) { + m.cursor = len(hand) - 1 + } +} + +func (m OneOhOneModel) currentHand() []Card { + if m.game.Phase != OneOhOnePhasePlay { + return nil + } + return m.game.Players[m.game.CurrentPlayerIdx].Hand +} + +func (m *OneOhOneModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *OneOhOneModel) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m OneOhOneModel) 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 OneOhOneModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Phase != OneOhOnePhasePlay { + return m, nil + } + idx := m.game.CurrentPlayerIdx + if idx == m.humanIdx { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game); err != nil { + m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + return m, nil + } + m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + if m.game.Phase == OneOhOnePhasePlay && m.game.CurrentPlayerIdx == m.humanIdx { + m.resetSelection() + } + return m, m.maybeScheduleBot() +} + +func (m OneOhOneModel) 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 == OneOhOnePhaseRoundOver { + if key == "n" && !m.game.Result.MatchOver { + m.game.NextRound() + m.message = "" + m.isError = false + m.resetSelection() + return m, m.maybeScheduleBot() + } + return m, nil + } + + if m.game.CurrentPlayerIdx != m.humanIdx { + return m, nil + } + + 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("Вы сыграли %s.", card) + if m.game.Phase == OneOhOnePhasePlay { + m.resetSelection() + } + return m, m.maybeScheduleBot() + case "d": + if err := m.game.Draw(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы взяли карту.") + if m.game.Phase == OneOhOnePhasePlay { + m.resetSelection() + } + return m, m.maybeScheduleBot() + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +func (m OneOhOneModel) renderHand() string { + hand := m.currentHand() + if len(hand) == 0 { + return dimStyle.Render("(нет карт)") + } + 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 OneOhOneModel) renderPlayers() string { + var b strings.Builder + for i, p := range m.game.Players { + marker := " " + if m.game.Phase == OneOhOnePhasePlay && i == m.game.CurrentPlayerIdx { + marker = "> " + } + name := p.Name + if i == m.humanIdx { + name += " (вы)" + } + status := "" + if p.Out { + status = " [выбыл]" + } + line := fmt.Sprintf("%s%-16s карт: %-2d счёт: %-4d%s", marker, name, len(p.Hand), p.Score, status) + if m.game.Phase == OneOhOnePhasePlay && i == m.game.CurrentPlayerIdx { + line = turnStyle.Render(line) + } + fmt.Fprintln(&b, line) + } + return strings.TrimRight(b.String(), "\n") +} + +func (m OneOhOneModel) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== 101 ===")) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderPlayers()) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Сброс сверху: %s В колоде: %d карт\n\n", renderCard(m.game.topDiscard()), len(m.game.Stock)) + + if m.game.Phase == OneOhOnePhaseRoundOver { + fmt.Fprintln(&b, m.renderResult()) + fmt.Fprintln(&b) + if m.game.Result.MatchOver { + fmt.Fprintln(&b, dimStyle.Render("[q] в меню")) + } else { + fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + } + return b.String() + } + + if m.game.CurrentPlayerIdx == m.humanIdx { + fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, m.renderHand()) + fmt.Fprintln(&b) + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] сыграть карту [d] взять из колоды (если нечем ходить) [q] в меню") + } else { + fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayerIdx].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 OneOhOneModel) renderResult() string { + res := m.game.Result + var b strings.Builder + if res.Stalemate { + fmt.Fprintln(&b, errorStyle.Render("Раунд зашёл в тупик и принудительно завершён без изменения счёта.")) + return strings.TrimRight(b.String(), "\n") + } + + winnerName := m.game.Players[res.Winner].Name + winLine := fmt.Sprintf("%s избавился(лась) от всех карт и выиграл(а) раунд!", winnerName) + if res.QueenBonus { + winLine += " (бонус за даму!)" + } + fmt.Fprintln(&b, winStyle.Render(winLine)) + fmt.Fprintln(&b) + + for i, p := range m.game.Players { + delta := res.ScoreDeltas[i] + note := "" + for _, e := range res.Eliminated { + if e == i { + note = " — выбыл(а) из партии!" + } + } + for _, r := range res.Reset { + if r == i { + note = " — ровно 101, счёт обнулён!" + } + } + fmt.Fprintln(&b, fmt.Sprintf(" %-16s %+d очков за раунд (итого: %d)%s", p.Name, delta, p.Score, note)) + } + + if res.MatchOver { + fmt.Fprintln(&b) + if res.NoWinner { + fmt.Fprintln(&b, errorStyle.Render("Партия завершилась без победителя (все выбыли одновременно).")) + } else { + fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Партия окончена! Победитель: %s", m.game.Players[res.MatchWinner].Name))) + } + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/oneohone_tui_test.go b/oneohone_tui_test.go new file mode 100644 index 0000000..a2e4237 --- /dev/null +++ b/oneohone_tui_test.go @@ -0,0 +1,118 @@ +package main + +import "testing" + +func TestOneOhOneModelDealsCorrectPlayerCount(t *testing.T) { + for n := 2; n <= 6; n++ { + m := NewOneOhOneModel(n) + if len(m.game.Players) != n { + t.Errorf("ожидалось %d игроков, получено %d", n, len(m.game.Players)) + } + } +} + +func TestOneOhOneModelClampsPlayerCount(t *testing.T) { + m := NewOneOhOneModel(1) + if len(m.game.Players) != 2 { + t.Errorf("ожидалось ограничение снизу до 2, получено %d", len(m.game.Players)) + } + m = NewOneOhOneModel(10) + if len(m.game.Players) != 6 { + t.Errorf("ожидалось ограничение сверху до 6, получено %d", len(m.game.Players)) + } +} + +func TestOneOhOneModelPlayCardViaKeys(t *testing.T) { + m := NewOneOhOneModel(2) + m.game.Discard = []Card{c(Nine, Clubs)} + m.game.Players[m.humanIdx].Hand = []Card{c(Ten, Clubs)} + m.game.CurrentPlayerIdx = m.humanIdx + m.game.Phase = OneOhOnePhasePlay + m.cursor = 0 + + next, _ := m.Update(key("enter")) + m = next.(OneOhOneModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } +} + +func TestOneOhOneModelDrawViaKeys(t *testing.T) { + m := NewOneOhOneModel(2) + m.game.Discard = []Card{c(Nine, Clubs)} + m.game.Players[m.humanIdx].Hand = []Card{c(Ten, Hearts)} // не подходит + m.game.Stock = []Card{c(Two, Diamonds)} + m.game.CurrentPlayerIdx = m.humanIdx + m.game.Phase = OneOhOnePhasePlay + + next, _ := m.Update(key("d")) + m = next.(OneOhOneModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if len(m.game.Players[m.humanIdx].Hand) != 2 { + t.Fatalf("рука должна была вырасти на 1 карту, получено %d карт", len(m.game.Players[m.humanIdx].Hand)) + } +} + +func TestOneOhOneModelQReturnsToMenu(t *testing.T) { + m := NewOneOhOneModel(2) + next, _ := m.Update(key("q")) + m = next.(OneOhOneModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +// TestOneOhOneModelFullAutoPlaythrough прогоняет TUI-модель от +// начала до конца матча, где "человек" отыгрывает свои ходы через +// реальные нажатия клавиш (используя ту же логику, что и бот). +func TestOneOhOneModelFullAutoPlaythrough(t *testing.T) { + for n := 2; n <= 4; n++ { + m := NewOneOhOneModel(n) + const maxRounds = 100 + round := 0 + for ; round < maxRounds; round++ { + const maxSteps = 6000 + steps := 0 + for m.game.Phase != OneOhOnePhaseRoundOver && steps < maxSteps { + steps++ + if m.game.CurrentPlayerIdx != m.humanIdx { + next, _ := m.Update(botMoveMsg{}) + m = next.(OneOhOneModel) + continue + } + if card, ok := m.bot.DecideAction(m.game); ok { + for i, hc := range m.currentHand() { + if hc == card { + m.cursor = i + break + } + } + next, _ := m.Update(key("enter")) + m = next.(OneOhOneModel) + } else { + next, _ := m.Update(key("d")) + m = next.(OneOhOneModel) + } + } + if steps >= maxSteps { + t.Fatalf("игроков %d, раунд %d: раунд через TUI не завершился за %d шагов", n, round, maxSteps) + } + if m.game.Result == nil { + t.Fatalf("игроков %d, раунд %d: раунд завершился без результата", n, round) + } + if m.game.Result.MatchOver { + break + } + next, _ := m.Update(key("n")) + m = next.(OneOhOneModel) + } + if round >= maxRounds { + t.Fatalf("игроков %d: матч не завершился за %d раундов", n, maxRounds) + } + } +} diff --git a/player.go b/player.go new file mode 100644 index 0000000..0ab45a9 --- /dev/null +++ b/player.go @@ -0,0 +1,51 @@ +package main + +// PlayerState — состояние одного игрока в партии. +type PlayerState struct { + Name string + Hand []Card + IsHuman bool + Balance int // текущий капитал игрока (в условных фишках/деньгах) +} + +// HandValue — суммарная очковая стоимость карт на руке игрока. +func (p *PlayerState) HandValue() int { + return HandValue(p.Hand) +} + +// HasCard проверяет наличие конкретной карты на руке. +func (p *PlayerState) HasCard(c Card) bool { + for _, card := range p.Hand { + if card == c { + return true + } + } + return false +} + +// RemoveCard удаляет карту из руки, если она там есть. Возвращает +// false, если карты не было (вызывающий код должен считать это +// программной ошибкой, а не игровой ситуацией). +func (p *PlayerState) RemoveCard(c Card) bool { + for i, card := range p.Hand { + if card == c { + p.Hand = append(p.Hand[:i], p.Hand[i+1:]...) + return true + } + } + return false +} + +// RemoveCards удаляет несколько карт разом. Если хотя бы одной не +// было на руке — не удаляет ничего и возвращает false. +func (p *PlayerState) RemoveCards(cards []Card) bool { + for _, c := range cards { + if !p.HasCard(c) { + return false + } + } + for _, c := range cards { + p.RemoveCard(c) + } + return true +} diff --git a/rules.go b/rules.go new file mode 100644 index 0000000..3fb3ce1 --- /dev/null +++ b/rules.go @@ -0,0 +1,440 @@ +package main + +// thousandRules — текст правил игры 1000 (базовый вариант строго +// на троих), используется и в TUI-экране "Правила", и при выводе +// через --help. +const thousandRules = `1000 (ТЫСЯЧА) — правила (строго на троих игроков) + +У "Тысячи" огромное количество договорённостей и вариантов (бочка, +золотой кон, самосвал, тузовый марьяж, игра втёмную, "расписать +игру" и т.д.). Реализован базовый, самый распространённый вариант +ИМЕННО НА ТРОИХ игроков — у игры вдвоём/вчетвером заметно другая +механика прикупа и сдающего, она не реализована. + +КОЛОДА И СТАРШИНСТВО + 24 карты: 9, В, Д, К, 10, Т в каждой масти. Старшинство карт: + 9 < В < Д < К < 10 < Т — да, десятка сильнее короля, но слабее + туза, это особенность именно этой игры. + +ОЧКИ КАРТ + Туз - 11, десятка - 10, король - 4, дама - 3, валет - 2, + девятка - 0. + +РАЗДАЧА И ТОРГИ + Каждому раздаётся по 7 карт, 3 карты уходят в прикуп. Игрок слева + от сдающего обязан назвать ставку не менее 100 (это то количество + очков, которое он берётся набрать за раздачу). Далее по кругу + каждый либо поднимает ставку минимум на 5, либо пасует — + спасовавший больше не участвует в этих торгах. Максимальная + ставка — 120, либо 120 плюс стоимость марьяжа, если он есть на + руке. Торги заканчиваются, когда остаётся один игрок — он берёт + прикуп и становится объявившим торг ("играющим"). + +СНОС + Объявивший торг берёт прикуп (10 карт на руке) и отдаёт по одной + карте каждому из двух соперников — после этого у всех троих + становится по 8 карт. Играющий ходит первым. + +ВЗЯТКИ + Обязательно ходить в масть; если её нет на руке — козырем (если + он уже объявлен в этой раздаче); если и козыря нет — любой картой. + Взятку забирает старшая карта хода, либо старший козырь, если он + был подложен. + +МАРЬЯЖ + Если у игрока, который сейчас ходит первым в новой взятке (кроме + самой первой взятки раздачи), есть на руке король и дама одной + масти — он может объявить марьяж, походив одной из этих карт. + Эта масть становится (или меняется на) козырь до конца раздачи, а + стоимость марьяжа сразу добавляется к очкам игрока за раздачу. + Стоимость: пики 40, трефы 60, бубны 80, червы 100. + +ПОДСЧЁТ ОЧКОВ + Если объявивший торг набрал (взятками и марьяжами) не меньше + своей ставки — ему засчитывается ставка целиком. Если меньше — + ставка вычитается из его счёта. Остальные добавляют себе ровно + то, что заработали, без штрафов. + +ОКОНЧАНИЕ ПАРТИИ + Игра идёт до 1000 очков — первый, кто их наберёт, побеждает. + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + В торгах: + ↑→/kl или ↓←/hj изменить предлагаемую ставку на ±5 + enter назвать текущую ставку + p пасовать + При сносе и в розыгрыше взяток: + ←→/hl выбор карты в руке + enter сыграть/отдать выбранную карту (король/дама одной масти + при своём первом ходе взятки = объявить марьяж) + В любой момент: + q выход в меню + После завершения раздачи: + n новая раздача (если партия ещё не окончена) + q выход в меню +` + +// klondikeRules — текст правил пасьянса Косынка (Клондайк), +// используется и в TUI-экране "Правила", и при выводе через --help. +const klondikeRules = `КОСЫНКА (KLONDIKE) — классический пасьянс на одного игрока + +Реализован классический "Клондайк" с добором по одной карте за раз +и неограниченным числом пересдач колоды из отбоя. + +ЦЕЛЬ + Собрать все 52 карты на четырёх фундаментах — по одному на + масть, от туза до короля по возрастанию. + +ПОЛЕ + 7 колонн (по 1-7 карт, в каждой открыта только самая нижняя из + выложенных карт), колода добора и отбой, 4 фундамента (по одному + на масть). + +ХОДЫ + - В колонну можно положить открытую карту на 1 меньше по рангу и + противоположного цвета масти (чёрная на красную или наоборот). + В пустую колонну можно положить только короля. + - На фундамент можно положить только следующую по рангу карту той + же масти, начиная с туза. + - Можно перекладывать между колоннами целиком собранную (по + убыванию, с чередованием цвета) последовательность карт — не + только одну карту сверху. "Разрезать" такую цепочку и взять + только часть нельзя. + - Когда карта под перемещённой цепочкой открывается — она + автоматически переворачивается лицом вверх. + - Из колоды добора карты берутся по одной в отбой; когда колода + добора заканчивается — весь отбой переворачивается обратно в + колоду для новой пересдачи (без ограничения числа пересдач). + - Возврат карт с фундамента обратно в игру не реализован. + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + Ход состоит из двух шагов: сначала выбор источника, потом — куда + переложить. + 1-7 выбрать колонну (или переложить выбранное в неё) + 0 выбрать отбой (только как источник) + f переложить выбранную карту на фундамент + esc отменить текущий выбор + d взять карту из колоды добора (пересдаст, если она пуста) + u отменить последний ход + n новая партия (перетасовать заново) + q выход в меню +` + +// checkersRules — текст правил русских шашек, используется и в +// TUI-экране "Правила", и при выводе через --help. +const checkersRules = `ШАШКИ (RUSSIAN CHECKERS) — против бота + +Реализованы русские шашки (не международные и не английские) на +доске 8х8. Вы всегда играете белыми и ходите первым; бот играет +чёрными (минимакс с альфа-бета отсечением). Перед началом партии +можно выбрать уровень сложности — "Простой" или "Сильный" (разница +только в глубине просчёта ходов бота). + +ЦЕЛЬ + Взять все шашки соперника либо оставить его без единого + возможного хода. + +ХОДЫ + Простая шашка ходит по диагонали на одну клетку только вперёд. + Дамка (шашка, дошедшая до последней горизонтали) ходит по + диагонали на любое расстояние в любом из четырёх направлений. + +ВЗЯТИЕ + Простая шашка бьёт через одну соседнюю клетку в ЛЮБОМ из четырёх + диагональных направлений, включая назад — не только вперёд. Дамка + бьёт "летающим" ходом: может перепрыгнуть через вражескую шашку с + любого расстояния (если путь до неё свободен) и приземлиться на + любую свободную клетку сразу за ней (тоже с выбором расстояния). + + Взятие ОБЯЗАТЕЛЬНО: если у вас есть хоть одно доступное взятие + где-либо на доске, простой ход запрещён. В отличие от + международных шашек, правило "бить максимально возможное число + шашек" не действует — подходит любое доступное взятие. Если после + взятия та же шашка может продолжить бить — она обязана продолжить + (ход не переходит сопернику, пока серия взятий не закончится). + Шашка, ставшая дамкой в середине серии взятий (дойдя до последней + горизонтали при ударе), продолжает эту же серию уже как дамка. + +ОКОНЧАНИЕ ПАРТИИ + Побеждает тот, у кого остались ходы, когда у соперника их не + осталось (нет шашек или все заблокированы). Если долго (60 + полуходов) не происходит ни одного взятия — партия принудительно + объявляется ничьей (упрощение официальных правил ничьей). + +УПРАВЛЕНИЕ + ↑↓←→ / hjkl передвижение курсора по доске + enter/space выбрать шашку (или переместить выбранную в клетку + под курсором) + esc отменить текущий выбор + q выход в меню + После завершения партии: + n новая партия + q выход в меню +` + +// blackjackRules — текст правил Блэкджека, используется и в +// TUI-экране "Правила", и при выводе через --help. +const blackjackRules = `БЛЭКДЖЕК — правила для 1-6 игроков + +Каждый игрок играет только против дилера — не друг против друга. +Можно играть даже в одиночку: тогда за столом только вы и дилер. + +ЦЕЛЬ + Набрать сумму очков ближе к 21, чем у дилера, не превысив 21. + +ОЧКИ КАРТ + Туз — 11 или 1 (автоматически считается как 1, если 11 привело бы + к перебору). Картинки (валет/дама/король) — 10. Остальные — по + номиналу. + +ПОДГОТОВКА И СТАВКА + В начале раунда каждый игрок делает фиксированную ставку. Игрокам + и дилеру раздаётся по 2 карты; у дилера одна карта открыта, вторая + скрыта до конца раунда. Блэкджек с раздачи — это туз и карта на 10 + очков в первых двух картах (сумма 21 ровно двумя картами). + +ХОД ИГРОКА + Каждый игрок по очереди решает: + - взять карту (hit) — можно повторять, пока не остановитесь или + не переберёте (сумма больше 21 — мгновенный проигрыш ставки); + - остановиться (stand) — зафиксировать текущую сумму; + - удвоить ставку (double down) — доступно только на первых двух + картах: ставка удваивается, берётся ровно одна карта, и ход + сразу заканчивается (переберёте вы или нет). + Сплиты (разделение пары карт на две руки) не поддерживаются. + +ХОД ДИЛЕРА + После того как все игроки закончили ходы, дилер открывает вторую + карту и берёт карты, пока сумма меньше 17. Дилер останавливается на + любых 17 и больше, включая "мягкие" 17 с тузом — так проще и + предсказуемее для игры через терминал. + +ВЫПЛАТЫ + - Блэкджек с раздачи (и у дилера его нет) — выигрыш 3:2. + - Блэкджек и у игрока, и у дилера — ничья, ставка возвращается. + - Перебор у игрока — проигрыш ставки, независимо от того, что + случится дальше у дилера. + - Дилер перебрал, а игрок нет — выигрыш 1:1. + - Сумма игрока больше суммы дилера (оба не перебрали) — выигрыш 1:1. + - Суммы равны — ничья, ставка возвращается. + - Сумма дилера больше — проигрыш ставки. + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + h взять карту + s остановиться + d удвоить ставку (только на первых двух картах) + q выход в меню + После завершения раунда: + n новый раунд с тем же капиталом и ставкой + q выход в меню +` + +// oneOhOneRules — текст правил игры 101, используется и в +// TUI-экране "Правила", и при выводе через --help. +const oneOhOneRules = `101 — правила для 2-6 игроков + +У игры "101" существует много вариантов, расходящихся в деталях. +Реализован следующий, наиболее часто встречающийся набор правил. + +ЦЕЛЬ + Как можно быстрее избавиться от всех карт на руке. Тот, кто первым + это сделал — выигрывает раунд; остальные получают штрафные очки за + карты, оставшиеся на руках. Партия идёт до тех пор, пока не + останется один игрок — набравшие 101 очко и более выбывают. + +ПОДГОТОВКА + Колода 36 карт (от шестёрки до туза), как и в Дураке. Сдающий + раздаёт по 5 карт каждому игроку, кроме себя — себе оставляет 4, а + пятую (лишнюю) кладёт в центр стола лицом вверх — это стартовая + карта сброса. Первым ходит игрок слева от сдающего. + +ХОД + Нужно положить карту той же масти или того же ранга, что и верхняя + карта сброса. Если подходящей карты нет — нужно брать карты из + колоды добора по одной, пока не найдётся подходящая (тогда она + сразу же играется) либо пока колода не закончится (тогда ход + просто переходит дальше). + +СПЕЦИАЛЬНЫЕ КАРТЫ + Туз — пропускает ход следующего игрока. + Шестёрка — следующий игрок берёт 1 карту и пропускает ход. + Семёрка — следующий игрок берёт 2 карты и пропускает ход. + Пиковый король — следующий игрок берёт 4 карты и пропускает ход. + Остальные короли и карты — обычные, без эффекта. + +ОЧКИ КАРТ (штраф за карты, оставшиеся на руках) + Туз - 11, десятка - 10, девятка - 0, восьмёрка - 8, семёрка - 7, + шестёрка - 6, король - 4, дама - 3, валет - 2. + +ОКОНЧАНИЕ РАУНДА + Победитель раунда получает 0 штрафных очков — а если его последней + сыгранной картой была дама, то бонус -20 очков (-40, если дама + червовая) вместо обычных 0. Остальные складывают очковую стоимость + карт, оставшихся у них на руках, и добавляют этот раунд к своему + общему счёту. + +ВЫБЫВАНИЕ И ОКОНЧАНИЕ ПАРТИИ + Набравший 101 очко и более выбывает из партии — если только это не + ровно 101, тогда счёт обнуляется (можно отыграться). Сдающим + следующего раунда становится победитель предыдущего. Партия + продолжается, пока не останется один игрок — он побеждает. + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + ←→ / hl выбор карты в руке + enter сыграть выбранную карту + d взять карту из колоды (доступно только если нет подходящей) + q выход в меню + После завершения раунда: + n новый раунд (если партия ещё не окончена) + q выход в меню +` + +// durakRules — текст правил Дурака (классический "подкидной" +// вариант в полном виде), используется и в TUI-экране "Правила", и +// при выводе через --help. +const durakRules = `ДУРАК — правила для 2-6 игроков + +Реализован классический "подкидной" Дурак в полном виде: подкидывать +дополнительные карты в раунде может любой игрок за столом, кроме +самого защищающегося — не только тот, кто открыл раунд. Отбивается +всегда только защищающийся, забрать стол может тоже только он. + +ПОДГОТОВКА + Колода 36 карт (от шестёрки до туза, без карт 2-5). Каждому + раздаётся по 6 карт. При 6 игроках колода не делится ровно — кому- + то может достаться на одну карту меньше, это нормально и по + правилам. Последняя карта колоды вскрывается — это козырь; она + остаётся в самом низу колоды добора и берётся последней. + Первым ходит игрок с самым младшим козырем на руке. + +ХОД + Атакующий обязательно открывает раунд, подкинув одну карту. + Защищающийся должен отбить её картой той же масти старше по рангу, + либо любым козырем (козырь бьёт некозырную карту любого ранга, но + козырь козырю нужно бить только старшим козырем). + + Как только очередная карта отбита, право подкинуть ещё одну карту + (того же ранга, что уже есть среди карт на столе) по очереди + получает каждый из остальных игроков, кроме защищающегося — начиная + с атакующего и по кругу. Каждый в свою очередь либо подкидывает + подходящую карту (тогда защищающийся должен снова отбиться или + забрать стол), либо пасует. Как только все подряд отказались + подкидывать — или лимит атаки исчерпан (не больше 6 карт на столе и + не больше числа карт на руке защищающегося на начало раунда) — + объявляется "бито": все карты со стола уходят в отбой навсегда. + + Если защищающийся не может (или не хочет) отбить карту — он + забирает весь стол себе в руку. Это не проигрыш, а обычный игровой + момент. + +СМЕНА РОЛЕЙ + После "бито" атакующим становится следующий по кругу после + защищающегося. После взятия стола атакующим становится следующий + по кругу после того, кто как раз забрал карты (он на этот раз + пропускает роль атакующего). + +ДОБОР КАРТ + После каждого раунда игроки добирают карты из колоды до 6 (если в + колоде столько есть), начиная с атакующего того раунда и по кругу, + защищающийся добирает последним. Если стол забрали — забравший + вообще не добирает в этот раз (у него и так теперь много карт). + +ОКОНЧАНИЕ ПАРТИИ + Как только у игрока не осталось карт на руке и в колоде добора + тоже пусто — он выходит из игры "в безопасности". Игра продолжается + для остальных. Последний, у кого остались карты на руках, когда все + остальные уже вышли — "дурак", он проигрывает. Если сразу несколько + игроков одновременно избавляются от последних карт — считается + ничья, дурака нет. + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + Когда вы открываете раунд (обязательный первый ход): + ←→ / hl выбор карты в руке + enter подкинуть выбранную карту + Когда вы защищающийся: + ←→ / hl выбор карты в руке + enter отбить выбранной картой последнюю неотбитую карту + t забрать весь стол себе в руку + Когда очередь подкидывать дошла до вас (стол уже отбит): + ←→ / hl выбор карты в руке + enter подкинуть ещё одну карту + p пасовать (отказаться подкидывать) + В любой момент: + q выход в меню + После завершения партии: + n новая партия с тем же числом игроков + q выход в меню +` + +// вариант), используется и в TUI-экране "Правила", и при выводе +// через --help. +const tonkRules = `ТОНК (TONK) — правила для 3-4 игроков + +ПОДГОТОВКА + Колода 52 карты без джокеров. Каждому раздаётся по 5 карт. + Оставшиеся карты — колода добора. Верхняя карта сразу вскрывается + рубашкой вниз рядом — так начинается стопка сброса. + +ХОД ИГРОКА + 1. Взять карту — либо верхнюю из колоды добора, либо верхнюю + из стопки сброса. + 2. Можно выложить на стол готовые комбинации: + - сет: 3+ карты одного ранга, разных мастей + - сиквенс: 3+ карты одной масти подряд по рангу + Туз может быть как младшей картой (A-2-3), так и старшей + (Q-K-A), но не одновременно ("закольцевать" через край нельзя). + 3. Можно подкладывать карты к уже выложенным комбинациям — + своим или чужим. + 4. В конце хода нужно сбросить одну карту (кроме случая, когда + последняя карта руки ушла в комбинацию — тогда раунд + заканчивается победой без отдельного сброса). + +ОЧКИ КАРТ + Туз = 1, валет/дама/король = 10, остальные — по номиналу. + +ОБЪЯВЛЕНИЕ "ТОНК" + В любой момент сразу после взятия карты (до выкладывания + комбинаций) игрок может объявить Тонк, если сумма очков карт на + руке не превышает порог (по умолчанию 5). Раунд немедленно + заканчивается победой объявившего — денежные детали см. в разделе + "ДЕНЬГИ" ниже. + +ЗАВЕРШЕНИЕ РАУНДА + - Игрок избавился от всех карт -> он забирает банк раунда целиком. + - Объявлен Тонк -> объявивший забирает банк, умноженный на + множитель Тонка (см. ниже); недостающую разницу сверх банка + поровну доплачивают остальные игроки. + - Колода добора и стопка сброса исчерпаны, и никто не может ни + выиграть, ни объявить Тонк -> раунд "мёртвый", банк возвращается + игрокам поровну (каждому — его ставка назад). + +ДЕНЬГИ: СТАВКА И БАНК + В начале каждого раунда каждый игрок вносит фиксированную ставку + (анте) в общий банк. Обычная победа (опустошением руки) забирает + банк как есть. Победа через объявление Тонка забирает банк, + умноженный на 2: недостающую сумму сверх банка поровну доплачивают + остальные игроки — то есть Тонк выгоднее для победителя, но дороже + обходится проигравшим, чем обычное поражение. При "мёртвом" раунде + деньги никто не выигрывает и не теряет — ставки просто возвращаются. + Капитал каждого игрока переносится из раунда в раунд, пока идёт + партия; в этой версии капитал может уйти в минус, если ставки + превышают остаток средств — отдельного правила банкротства пока нет. + + +УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ + Фаза взятия карты: + d взять из колоды добора + p взять из сброса + Фаза действия (после взятия карты): + ←→ / hl выбор карты в руке + space отметить/снять отметку карты для новой комбинации + m выложить отмеченные карты как комбинацию (сет/сиквенс) + 1-9 подложить выбранную карту к комбинации №N на столе + t объявить Тонк (доступно, если вес руки не выше порога) + enter сбросить выбранную карту и завершить ход + В любой момент: + q выход + После завершения раунда: + n новый раунд (счёт сохраняется) + q выход +` diff --git a/thousand_bot.go b/thousand_bot.go new file mode 100644 index 0000000..795e8cf --- /dev/null +++ b/thousand_bot.go @@ -0,0 +1,221 @@ +package main + +import "math/rand" + +// thousandNamePool — имена ботов в 1000. Отдельный пул — мужские +// имена для разнообразия среди игр коллекции. +var thousandNamePool = []string{ + "Виктор", "Роман", "Максим", "Артём", "Кирилл", "Егор", "Денис", "Павел", +} + +func pickThousandBotName(used map[string]bool) string { + free := make([]string, 0, len(thousandNamePool)) + for _, name := range thousandNamePool { + if !used[name] { + free = append(free, name) + } + } + if len(free) == 0 { + free = thousandNamePool + } + return free[rand.Intn(len(free))] +} + +func newThousandBotNames(n int) []string { + used := make(map[string]bool, n) + names := make([]string, n) + for i := 0; i < n; i++ { + name := pickThousandBotName(used) + used[name] = true + names[i] = name + } + return names +} + +// ThousandBot — единственная (без градации уровней) стратегия +// бота для 1000: оценивает силу руки для торгов, сбрасывает +// заведомо слабые карты, старается объявлять марьяжи и играть +// дёшево там, где нельзя выиграть взятку. +type ThousandBot struct{} + +// estimateHandStrength грубо оценивает, сколько очков реалистично +// набрать с такой рукой: сумма очков карт на руке плюс стоимость +// лучшего марьяжа (марьяж считается один раз, даже если их +// несколько, — консервативная оценка). +func estimateHandStrength(hand []Card) int { + sum := 0 + for _, c := range hand { + sum += thousandCardValue(c.Rank) + } + return sum + bestMarriageValue(hand) +} + +// DecideBid решает, поднимать ли ставку в текущих торгах и до +// какой суммы, либо пасовать. Возвращает (amount, true), если нужно +// вызвать g.Bid(amount), иначе (_, false) для g.Pass(). +func (ThousandBot) DecideBid(g *ThousandGameState) (int, bool) { + p := g.current() + estimate := estimateHandStrength(p.Hand) + capAmt := g.maxBidFor(g.CurrentBidderIdx) + + minRequired := g.HighBid + 5 + mandatory := g.bidsMade == 0 && g.CurrentBidderIdx == g.firstBidderIdx + if mandatory { + minRequired = 100 + } + + if minRequired > capAmt { + if mandatory { + return 100, true + } + return 0, false + } + if estimate+10 < minRequired && !mandatory { + return 0, false + } + bid := minRequired + if bid > capAmt { + bid = capAmt + } + return bid, true +} + +// DecideDiscards выбирает 2 карты для сноса сопернику: самые +// дешёвые карты руки, по возможности не разбивая марьяж. +func (ThousandBot) DecideDiscards(hand []Card) [2]Card { + type scored struct { + card Card + cost int + } + list := make([]scored, len(hand)) + for i, c := range hand { + cost := thousandCardValue(c.Rank) + if (c.Rank == King || c.Rank == Queen) && hasMarriage(hand, c.Suit) { + cost += 1000 + } + list[i] = scored{card: c, cost: cost} + } + for i := 0; i < len(list); i++ { + for j := i + 1; j < len(list); j++ { + if list[j].cost < list[i].cost { + list[i], list[j] = list[j], list[i] + } + } + } + return [2]Card{list[0].card, list[1].card} +} + +// DecidePlay решает, какую карту сыграть в текущей взятке: если +// ходит первым и есть необъявленный марьяж (не на первой взятке +// раздачи) — предпочитает объявить самый дорогой; иначе играет +// самую дешёвую из разрешённых карт (по возможности выигрышную +// дёшево, а если выиграть нельзя — минимальные потери). +func (ThousandBot) DecidePlay(g *ThousandGameState) Card { + actor := g.currentTrickActor() + hand := g.Players[actor].Hand + + leading := len(g.CurrentTrick) == 0 + if leading && g.TricksPlayed > 0 { + bestSuit := Suit(-1) + bestValue := -1 + for s := Clubs; s <= Spades; s++ { + if hasMarriage(hand, s) { + if v := thousandMarriageValue[s]; v > bestValue { + bestValue = v + bestSuit = s + } + } + } + if bestValue >= 0 { + return Card{Suit: bestSuit, Rank: King} + } + } + + var legal []Card + for _, c := range hand { + if leading || g.isValidPlay(hand, c) { + legal = append(legal, c) + } + } + if len(legal) == 0 { + legal = hand + } + + if leading { + return weakestByThousandRank(legal) + } + + led, _ := g.ledSuit() + currentBest := g.CurrentTrick[0] + bestIsTrump := g.TrumpSet && currentBest.Card.Suit == g.TrumpSuit + for _, play := range g.CurrentTrick[1:] { + isTrump := g.TrumpSet && play.Card.Suit == g.TrumpSuit + if (isTrump && !bestIsTrump) || + (isTrump == bestIsTrump && play.Card.Suit == currentBest.Card.Suit && + thousandRankOrder[play.Card.Rank] > thousandRankOrder[currentBest.Card.Rank]) { + currentBest = play + bestIsTrump = isTrump + } + } + + var winners []Card + for _, c := range legal { + isTrump := g.TrumpSet && c.Suit == g.TrumpSuit + wins := false + switch { + case isTrump && !bestIsTrump: + wins = true + case isTrump == bestIsTrump && c.Suit == currentBest.Card.Suit && + thousandRankOrder[c.Rank] > thousandRankOrder[currentBest.Card.Rank]: + wins = true + case !bestIsTrump && c.Suit == led && currentBest.Card.Suit != led: + wins = true + } + if wins { + winners = append(winners, c) + } + } + if len(winners) > 0 { + return weakestByThousandRank(winners) + } + return weakestByThousandRank(legal) +} + +// weakestByThousandRank находит карту с наименьшим старшинством в +// наборе (при равенстве — с меньшей очковой стоимостью). +func weakestByThousandRank(cards []Card) Card { + best := cards[0] + for _, c := range cards[1:] { + if thousandRankOrder[c.Rank] < thousandRankOrder[best.Rank] || + (thousandRankOrder[c.Rank] == thousandRankOrder[best.Rank] && thousandCardValue(c.Rank) < thousandCardValue(best.Rank)) { + best = c + } + } + return best +} + +// PlayFullTurn исполняет одно решение бота (в контексте 1000 — +// один шаг торгов, снос, или один ход во взятке) — используется +// при автоматической прокрутке партии. +func (b ThousandBot) PlayFullTurn(g *ThousandGameState) error { + switch g.Phase { + case ThousandPhaseBidding: + if amount, ok := b.DecideBid(g); ok { + return g.Bid(amount) + } + return g.Pass() + case ThousandPhaseDiscard: + discards := b.DecideDiscards(g.Players[g.DeclarerIdx].Hand) + if err := g.DiscardCard(discards[0]); err != nil { + return err + } + if g.Phase != ThousandPhaseDiscard { + return nil + } + return g.DiscardCard(discards[1]) + case ThousandPhaseTrick: + card := b.DecidePlay(g) + return g.PlayCard(card) + } + return nil +} diff --git a/thousand_bot_test.go b/thousand_bot_test.go new file mode 100644 index 0000000..f2e9e3e --- /dev/null +++ b/thousand_bot_test.go @@ -0,0 +1,59 @@ +package main + +import "testing" + +// TestThousandBotFullMatches прогоняет много полных матчей (до +// 1000 очков) силами ThousandBot, проверяя отсутствие паник, +// зависаний и потери/дублирования карт. +func TestThousandBotFullMatches(t *testing.T) { + bot := ThousandBot{} + + for match := 0; match < 20; match++ { + names := newThousandBotNames(3) + g := NewThousandGame(names, -1) + + const maxHands = 500 + hand := 0 + for ; hand < maxHands; hand++ { + const maxSteps = 2000 + steps := 0 + for ; steps < maxSteps && g.Phase != ThousandPhaseHandOver; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("матч %d, раздача %d, шаг %d: ошибка бота (фаза %v): %v", + match, hand, steps, g.Phase, err) + } + } + if steps >= maxSteps { + t.Fatalf("матч %d, раздача %d: раздача не завершилась за %d шагов (фаза %v)", + match, hand, maxSteps, g.Phase) + } + if g.Result == nil { + t.Fatalf("матч %d, раздача %d: раздача завершилась без результата", match, hand) + } + + seen := map[Card]bool{} + total := 0 + for _, p := range g.Players { + for _, c := range p.Hand { + if seen[c] { + t.Fatalf("матч %d, раздача %d: карта %v встретилась дважды", match, hand, c) + } + seen[c] = true + total++ + } + } + if total != 0 { + t.Fatalf("матч %d, раздача %d: после завершения раздачи на руках не должно остаться карт, получено %d", + match, hand, total) + } + + if g.Result.MatchOver { + break + } + g.NextHand() + } + if hand >= maxHands { + t.Fatalf("матч %d: не завершился за %d раздач (возможное зависание)", match, maxHands) + } + } +} diff --git a/thousand_game.go b/thousand_game.go new file mode 100644 index 0000000..8763576 --- /dev/null +++ b/thousand_game.go @@ -0,0 +1,502 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: у "Тысячи" очень много вариантов и +// договорённостей (бочка, золотой кон, самосвал, тузовый марьяж, +// игра втёмную, "расписать игру" и т.д.). Реализован базовый, +// самый распространённый вариант ИМЕННО НА ТРОИХ игроков (у игры +// вдвоём/вчетвером заметно другая механика прикупа и сдающего — +// сознательно не реализована). Набор правил: +// - Колода 24 карты (9,В,Д,К,10,Т в каждой масти). Старшинство: +// 9 < В < Д < К < 10 < Т (да, десятка сильнее короля, но +// слабее туза — особенность именно этой игры). +// - Раздача: по 7 карт каждому, 3 карты — в прикуп. +// - Торги: первый ходящий (слева от сдающего) обязан назвать +// не менее 100 очков. Далее по кругу каждый либо поднимает +// ставку (шаг 5, до 120 без марьяжа на руке, либо до +// 120+стоимость марьяжа, если марьяж есть), либо пасует. +// Спасовавший больше не участвует в этих торгах. Торги +// заканчиваются, когда остаётся один игрок — он берёт прикуп. +// - Победитель торгов забирает прикуп (10 карт на руке), отдаёт +// по одной карте каждому из двух соперников (по одной, +// соперникам становится по 8 карт, у него самого остаётся 8) и +// ходит первым. +// - Взятки: обязательно ходить в масть; если её нет — козырем +// (если он уже объявлен); если и его нет — чем угодно. Взятку +// берёт старшая карта хода, либо старший козырь, если он был +// подложен. +// - Марьяж: если у игрока, который сейчас ходит (не в первой +// взятке раздачи), есть на руке дама и король одной масти, он +// может объявить марьяж, походив с одной из этих карт — тогда +// эта масть становится (или меняется на) козырь до конца +// раздачи, а стоимость марьяжа сразу добавляется к очкам +// игрока за эту раздачу. Стоимость: пики 40, трефы 60, бубны +// 80, червы 100. +// - Подсчёт очков по итогам раздачи: если объявивший торг игрок +// набрал (взятками и марьяжами) не меньше своей ставки — ему +// засчитывается ставка целиком; если меньше — ставка вычитается +// из его счёта. Остальные добавляют себе ровно то, что +// заработали (взятки+марьяжи), без штрафов. +// - Партия идёт до 1000 очков. "Бочка", "золотой кон", +// "самосвал", тузовый марьяж и игра "втёмную" — не реализованы. + +// ThousandPlayer — состояние одного игрока в партии 1000. +type ThousandPlayer struct { + Name string + Hand []Card + IsHuman bool + Score int // суммарный счёт по итогам сыгранных раздач + HandPoints int // очки (взятки+марьяжи), набранные в ТЕКУЩЕЙ раздаче + Passed bool +} + +// ThousandPhase — фаза текущей раздачи. +type ThousandPhase int + +const ( + ThousandPhaseBidding ThousandPhase = iota + ThousandPhaseDiscard + ThousandPhaseTrick + ThousandPhaseHandOver +) + +var ( + ErrThousandWrongPhase = errors.New("недопустимое действие для текущей фазы") + ErrThousandCardNotInHand = errors.New("карты нет на руке игрока") + ErrThousandBidTooLow = errors.New("ставка должна быть выше текущей минимум на 5") + ErrThousandBidTooHigh = errors.New("ставка превышает разрешённый максимум (120 плюс марьяж на руке)") + ErrThousandMustBid = errors.New("первый ходящий обязан назвать ставку не менее 100 и не может пасовать") + ErrThousandInvalidFollow = errors.New("нужно ходить в масть или козырем, если они есть на руке") + ErrThousandAlreadyDiscarded = errors.New("уже отдано нужное количество карт") +) + +// thousandRankOrder — старшинство рангов в Тысяче: девятка младше +// всех, туз старше всех, но десятка стоит МЕЖДУ королём и тузом. +var thousandRankOrder = map[Rank]int{ + Nine: 0, Jack: 1, Queen: 2, King: 3, Ten: 4, Ace: 5, +} + +// thousandMarriageValue — стоимость козырного марьяжа по масти. +var thousandMarriageValue = map[Suit]int{ + Spades: 40, Clubs: 60, Diamonds: 80, Hearts: 100, +} + +// NewThousandDeck создаёт колоду для Тысячи: 24 карты (9,В,Д,К,10,Т +// в каждой масти). +func NewThousandDeck() *Deck { + ranks := []Rank{Nine, Jack, Queen, King, Ten, Ace} + d := &Deck{cards: make([]Card, 0, 24)} + for s := Clubs; s <= Spades; s++ { + for _, r := range ranks { + d.cards = append(d.cards, Card{Suit: s, Rank: r}) + } + } + return d +} + +// thousandCardValue — очковая стоимость карты (та же шкала, что и +// в 101: туз 11, десятка 10, король 4, дама 3, валет 2, девятка 0). +func thousandCardValue(r Rank) int { + return oneOhOneCardValue(r) +} + +// hasMarriage проверяет, есть ли на руке король и дама указанной +// масти. +func hasMarriage(hand []Card, suit Suit) bool { + hasKing, hasQueen := false, false + for _, c := range hand { + if c.Suit != suit { + continue + } + if c.Rank == King { + hasKing = true + } + if c.Rank == Queen { + hasQueen = true + } + } + return hasKing && hasQueen +} + +// bestMarriageValue возвращает наибольшую стоимость марьяжа, +// имеющегося на руке (0, если марьяжей нет). +func bestMarriageValue(hand []Card) int { + best := 0 + for s := Clubs; s <= Spades; s++ { + if hasMarriage(hand, s) { + if v := thousandMarriageValue[s]; v > best { + best = v + } + } + } + return best +} + +type thousandTrickPlay struct { + PlayerIdx int + Card Card +} + +// ThousandHandResult — итог одной завершившейся раздачи. +type ThousandHandResult struct { + DeclarerIdx int + FinalBid int + Made bool // выполнил ли объявивший торг свою ставку + ScoreDeltas []int + MatchOver bool + MatchWinner int +} + +// ThousandGameState — полное состояние партии в 1000 (ровно на 3 +// игроков). +type ThousandGameState struct { + Players []*ThousandPlayer + Kitty []Card + + DealerIdx int + Phase ThousandPhase + Result *ThousandHandResult + + // --- Торги --- + CurrentBidderIdx int + HighBid int + HighBidderIdx int + firstBidderIdx int + bidsMade int + + // --- Прикуп/снос --- + DeclarerIdx int + FinalBid int + discardsGiven int + discardTargets []int // получатели 1-й и 2-й отданной карты + + // --- Взятка --- + TrumpSuit Suit + TrumpSet bool + LeaderIdx int + CurrentTrick []thousandTrickPlay + TricksPlayed int +} + +// MatchTarget — партия идёт до этой суммы очков. +const ThousandMatchTarget = 1000 + +// NewThousandGame создаёт новую партию на ровно 3 игроков. +func NewThousandGame(names []string, humanIdx int) *ThousandGameState { + if len(names) != 3 { + panic("1000 поддерживается только на троих игроков") + } + players := make([]*ThousandPlayer, 3) + for i, name := range names { + players[i] = &ThousandPlayer{Name: name, IsHuman: i == humanIdx} + } + g := &ThousandGameState{Players: players, DealerIdx: 0} + g.dealNewHand() + return g +} + +func (g *ThousandGameState) dealNewHand() { + deck := NewThousandDeck() + deck.Shuffle() + + for _, p := range g.Players { + p.Hand = nil + p.HandPoints = 0 + p.Passed = false + } + + for i := 0; i < 3; i++ { + idx := (g.DealerIdx + 1 + i) % 3 + g.Players[idx].Hand = append(g.Players[idx].Hand, deck.cards[:7]...) + deck.cards = deck.cards[7:] + } + g.Kitty = append([]Card{}, deck.cards[:3]...) + + g.firstBidderIdx = (g.DealerIdx + 1) % 3 + g.CurrentBidderIdx = g.firstBidderIdx + g.HighBid = 0 + g.HighBidderIdx = -1 + g.bidsMade = 0 + g.DeclarerIdx = -1 + g.FinalBid = 0 + g.discardsGiven = 0 + g.discardTargets = []int{(g.firstBidderIdx)} + g.TrumpSet = false + g.CurrentTrick = nil + g.TricksPlayed = 0 + g.Result = nil + g.Phase = ThousandPhaseBidding +} + +func (g *ThousandGameState) current() *ThousandPlayer { + return g.Players[g.CurrentBidderIdx] +} + +func (g *ThousandGameState) maxBidFor(playerIdx int) int { + return 120 + bestMarriageValue(g.Players[playerIdx].Hand) +} + +func (g *ThousandGameState) nextBidder(from int) int { + for i := 1; i <= 3; i++ { + idx := (from + i) % 3 + if !g.Players[idx].Passed { + return idx + } + } + return from +} + +func (g *ThousandGameState) activeBidderCount() int { + n := 0 + for _, p := range g.Players { + if !p.Passed { + n++ + } + } + return n +} + +// Bid — текущий игрок в торгах называет ставку. +func (g *ThousandGameState) Bid(amount int) error { + if g.Phase != ThousandPhaseBidding { + return ErrThousandWrongPhase + } + if amount%5 != 0 { + return ErrThousandBidTooLow + } + minRequired := g.HighBid + 5 + if g.bidsMade == 0 { + minRequired = 100 + } + if amount < minRequired { + return ErrThousandBidTooLow + } + if amount > g.maxBidFor(g.CurrentBidderIdx) { + return ErrThousandBidTooHigh + } + + g.HighBid = amount + g.HighBidderIdx = g.CurrentBidderIdx + g.bidsMade++ + g.advanceOrFinishBidding() + return nil +} + +// Pass — текущий игрок в торгах отказывается от дальнейшего +// участия. Самый первый ходящий обязан назвать ставку и не может +// пасовать. +func (g *ThousandGameState) Pass() error { + if g.Phase != ThousandPhaseBidding { + return ErrThousandWrongPhase + } + if g.bidsMade == 0 && g.CurrentBidderIdx == g.firstBidderIdx { + return ErrThousandMustBid + } + g.Players[g.CurrentBidderIdx].Passed = true + g.advanceOrFinishBidding() + return nil +} + +func (g *ThousandGameState) advanceOrFinishBidding() { + if g.activeBidderCount() <= 1 { + g.finishBidding() + return + } + g.CurrentBidderIdx = g.nextBidder(g.CurrentBidderIdx) +} + +func (g *ThousandGameState) finishBidding() { + g.DeclarerIdx = g.HighBidderIdx + g.FinalBid = g.HighBid + g.Players[g.DeclarerIdx].Hand = append(g.Players[g.DeclarerIdx].Hand, g.Kitty...) + g.discardTargets = []int{ + (g.DeclarerIdx + 1) % 3, + (g.DeclarerIdx + 2) % 3, + } + g.discardsGiven = 0 + g.Phase = ThousandPhaseDiscard +} + +// DiscardCard — объявивший торг игрок (declarer) отдаёт одну карту +// следующему по очереди сопернику. Вызывается дважды подряд. +func (g *ThousandGameState) DiscardCard(card Card) error { + if g.Phase != ThousandPhaseDiscard { + return ErrThousandWrongPhase + } + if g.discardsGiven >= 2 { + return ErrThousandAlreadyDiscarded + } + p := g.Players[g.DeclarerIdx] + if !hasCard(p.Hand, card) { + return ErrThousandCardNotInHand + } + p.Hand = removeCard(p.Hand, card) + target := g.discardTargets[g.discardsGiven] + g.Players[target].Hand = append(g.Players[target].Hand, card) + g.discardsGiven++ + + if g.discardsGiven == 2 { + g.LeaderIdx = g.DeclarerIdx + g.Phase = ThousandPhaseTrick + } + return nil +} + +func (g *ThousandGameState) ledSuit() (Suit, bool) { + if len(g.CurrentTrick) == 0 { + return 0, false + } + return g.CurrentTrick[0].Card.Suit, true +} + +func containsSuit(hand []Card, suit Suit) bool { + for _, c := range hand { + if c.Suit == suit { + return true + } + } + return false +} + +// isValidPlay проверяет, разрешено ли сыграть card в текущей +// взятке для игрока с рукой hand. +func (g *ThousandGameState) isValidPlay(hand []Card, card Card) bool { + led, ok := g.ledSuit() + if !ok { + return true // ходит первым — любая карта разрешена (кроме марьяжа см. отдельно) + } + if containsSuit(hand, led) { + return card.Suit == led + } + if g.TrumpSet && containsSuit(hand, g.TrumpSuit) { + return card.Suit == g.TrumpSuit + } + return true +} + +// PlayCard — текущий игрок в раскладе взятки играет карту. Если +// это первая карта взятки (игрок ходит), король или дама масти, по +// которой на руке есть парная карта, и это не самая первая взятка +// раздачи — автоматически объявляется марьяж (козырь меняется на +// эту масть, очки марьяжа сразу начисляются). +func (g *ThousandGameState) PlayCard(card Card) error { + if g.Phase != ThousandPhaseTrick { + return ErrThousandWrongPhase + } + actor := g.currentTrickActor() + p := g.Players[actor] + if !hasCard(p.Hand, card) { + return ErrThousandCardNotInHand + } + + leading := len(g.CurrentTrick) == 0 + if !leading { + if !g.isValidPlay(p.Hand, card) { + return ErrThousandInvalidFollow + } + } else if (card.Rank == King || card.Rank == Queen) && g.TricksPlayed > 0 && hasMarriage(p.Hand, card.Suit) { + p.HandPoints += thousandMarriageValue[card.Suit] + g.TrumpSuit = card.Suit + g.TrumpSet = true + } + + p.Hand = removeCard(p.Hand, card) + g.CurrentTrick = append(g.CurrentTrick, thousandTrickPlay{PlayerIdx: actor, Card: card}) + + if len(g.CurrentTrick) == 3 { + g.resolveTrick() + } + return nil +} + +// currentTrickActor возвращает индекс игрока, чья очередь класть +// карту в текущей взятке. +func (g *ThousandGameState) currentTrickActor() int { + return (g.LeaderIdx + len(g.CurrentTrick)) % 3 +} + +func (g *ThousandGameState) resolveTrick() { + led := g.CurrentTrick[0].Card.Suit + best := g.CurrentTrick[0] + bestIsTrump := g.TrumpSet && best.Card.Suit == g.TrumpSuit + + for _, play := range g.CurrentTrick[1:] { + isTrump := g.TrumpSet && play.Card.Suit == g.TrumpSuit + switch { + case isTrump && !bestIsTrump: + best = play + bestIsTrump = true + case isTrump == bestIsTrump && play.Card.Suit == best.Card.Suit && thousandRankOrder[play.Card.Rank] > thousandRankOrder[best.Card.Rank]: + best = play + case !isTrump && !bestIsTrump && play.Card.Suit == led && best.Card.Suit != led: + best = play + } + } + + points := 0 + for _, play := range g.CurrentTrick { + points += thousandCardValue(play.Card.Rank) + } + g.Players[best.PlayerIdx].HandPoints += points + + g.LeaderIdx = best.PlayerIdx + g.CurrentTrick = nil + g.TricksPlayed++ + + if g.TricksPlayed == 8 { + g.resolveHandEnd() + } +} + +func (g *ThousandGameState) resolveHandEnd() { + deltas := make([]int, 3) + made := g.Players[g.DeclarerIdx].HandPoints >= g.FinalBid + if made { + deltas[g.DeclarerIdx] = g.FinalBid + } else { + deltas[g.DeclarerIdx] = -g.FinalBid + } + for i, p := range g.Players { + if i == g.DeclarerIdx { + continue + } + deltas[i] = p.HandPoints + } + for i, p := range g.Players { + p.Score += deltas[i] + } + + result := &ThousandHandResult{ + DeclarerIdx: g.DeclarerIdx, + FinalBid: g.FinalBid, + Made: made, + ScoreDeltas: deltas, + } + + bestIdx := -1 + for i, p := range g.Players { + if p.Score >= ThousandMatchTarget { + if bestIdx == -1 || p.Score > g.Players[bestIdx].Score { + bestIdx = i + } + } + } + if bestIdx != -1 { + result.MatchOver = true + result.MatchWinner = bestIdx + } + + g.Result = result + g.Phase = ThousandPhaseHandOver +} + +// NextHand начинает новую раздачу (сдающий переходит по кругу), +// сохраняя счёт игроков. +func (g *ThousandGameState) NextHand() { + if g.Result != nil && g.Result.MatchOver { + return + } + g.DealerIdx = (g.DealerIdx + 1) % 3 + g.dealNewHand() +} diff --git a/thousand_game_test.go b/thousand_game_test.go new file mode 100644 index 0000000..677ae63 --- /dev/null +++ b/thousand_game_test.go @@ -0,0 +1,399 @@ +package main + +import "testing" + +func TestThousandDealsCorrectly(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + for i, p := range g.Players { + if len(p.Hand) != 7 { + t.Errorf("игрок %d: ожидалось 7 карт, получено %d", i, len(p.Hand)) + } + } + if len(g.Kitty) != 3 { + t.Fatalf("ожидалось 3 карты в прикупе, получено %d", len(g.Kitty)) + } + if g.CurrentBidderIdx != (g.DealerIdx+1)%3 { + t.Errorf("первым в торгах должен ходить игрок слева от сдающего") + } +} + +func TestFirstBidderMustBidAtLeast100(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + if err := g.Pass(); err != ErrThousandMustBid { + t.Errorf("первый ходящий не может пасовать, ожидалась ErrThousandMustBid, получено: %v", err) + } + if err := g.Bid(95); err != ErrThousandBidTooLow { + t.Errorf("ставка меньше 100 должна быть отклонена, получено: %v", err) + } + if err := g.Bid(100); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } +} + +func TestBidMustBeMultipleOf5AndHigherThanCurrent(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + if err := g.Bid(100); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Bid(102); err != ErrThousandBidTooLow { + t.Errorf("ставка не кратная 5 должна быть отклонена, получено: %v", err) + } + if err := g.Bid(100); err != ErrThousandBidTooLow { + t.Errorf("ставка не выше текущей должна быть отклонена, получено: %v", err) + } + if err := g.Bid(105); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } +} + +func TestBidCappedAt120WithoutMarriage(t *testing.T) { + noMarriageHand := []Card{ + c(Nine, Clubs), c(Jack, Clubs), c(Nine, Hearts), + c(Jack, Hearts), c(Nine, Diamonds), c(Jack, Diamonds), c(Nine, Spades), + } + + g := NewThousandGame([]string{"A", "B", "C"}, -1) + g.Players[g.CurrentBidderIdx].Hand = append([]Card{}, noMarriageHand...) + if err := g.Bid(120); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + + // проверяем превышение кэпа отдельной игрой — чтобы ставка 125 + // проверялась для ТОГО ЖЕ игрока с той же рукой, а не для + // следующего (ход уже передан после успешной ставки выше) + g2 := NewThousandGame([]string{"A", "B", "C"}, -1) + g2.Players[g2.CurrentBidderIdx].Hand = append([]Card{}, noMarriageHand...) + if err := g2.Bid(125); err != ErrThousandBidTooHigh { + t.Errorf("без марьяжа ставка выше 120 должна быть отклонена, получено: %v", err) + } +} + +func TestBidCapRaisedByMarriage(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + g.Players[g.CurrentBidderIdx].Hand = []Card{ + c(King, Hearts), c(Queen, Hearts), c(Nine, Clubs), + c(Nine, Diamonds), c(Nine, Spades), c(Jack, Clubs), c(Jack, Diamonds), + } + if err := g.Bid(220); err != nil { + t.Fatalf("ставка 120+100 должна быть разрешена при червовом марьяже: %v", err) + } + g2 := NewThousandGame([]string{"A", "B", "C"}, -1) + g2.Players[g2.CurrentBidderIdx].Hand = []Card{ + c(King, Hearts), c(Queen, Hearts), c(Nine, Clubs), + c(Nine, Diamonds), c(Nine, Spades), c(Jack, Clubs), c(Jack, Diamonds), + } + if err := g2.Bid(225); err != ErrThousandBidTooHigh { + t.Errorf("ставка выше 220 должна быть отклонена, получено: %v", err) + } +} + +func TestBiddingEndsWhenOneBidderRemains(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + first := g.CurrentBidderIdx + if err := g.Bid(100); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != ThousandPhaseDiscard { + t.Fatalf("торги должны были завершиться после 2 пасов, фаза: %v", g.Phase) + } + if g.DeclarerIdx != first { + t.Errorf("объявившим торг должен стать %d, получено %d", first, g.DeclarerIdx) + } + if g.FinalBid != 100 { + t.Errorf("ожидалась итоговая ставка 100, получено %d", g.FinalBid) + } + if len(g.Players[first].Hand) != 10 { + t.Errorf("объявивший торг должен был получить прикуп (10 карт), получено %d", len(g.Players[first].Hand)) + } +} + +func TestDiscardGivesCardsToOpponentsAndStartsTrick(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + if err := g.Bid(100); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + declarer := g.DeclarerIdx + hand := append([]Card{}, g.Players[declarer].Hand...) + + if err := g.DiscardCard(hand[0]); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != ThousandPhaseDiscard { + t.Fatalf("после первого сброса раздача ещё не должна была начаться") + } + if err := g.DiscardCard(hand[1]); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Phase != ThousandPhaseTrick { + t.Fatalf("после второго сброса должна была начаться игра взятками") + } + if len(g.Players[declarer].Hand) != 8 { + t.Errorf("у объявившего торг должно остаться 8 карт, получено %d", len(g.Players[declarer].Hand)) + } + for i, p := range g.Players { + if i == declarer { + continue + } + if len(p.Hand) != 8 { + t.Errorf("у игрока %d должно стать 8 карт после получения снoса, получено %d", i, len(p.Hand)) + } + } + if g.LeaderIdx != declarer { + t.Errorf("объявивший торг должен ходить первым") + } +} + +// setupThousandTrickPlay — тестовый хелпер: пропускает торги и снос, +// вручную задаёт руки игроков и переводит игру сразу в фазу взятки. +func setupThousandTrickPlay(g *ThousandGameState, declarerIdx int, hands map[int][]Card) { + g.DeclarerIdx = declarerIdx + g.FinalBid = 100 + for idx, hand := range hands { + g.Players[idx].Hand = hand + g.Players[idx].HandPoints = 0 + } + g.LeaderIdx = declarerIdx + g.CurrentTrick = nil + g.TricksPlayed = 0 + g.TrumpSet = false + g.Phase = ThousandPhaseTrick +} + +func TestPlayCardFollowSuitRequired(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(Nine, Clubs)}, + 1: {c(Nine, Hearts), c(Jack, Clubs)}, + 2: {c(Nine, Spades)}, + }) + if err := g.PlayCard(c(Nine, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Nine, Hearts)); err != ErrThousandInvalidFollow { + t.Errorf("ожидалась ошибка обязательной масти, получено: %v", err) + } + if err := g.PlayCard(c(Jack, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка при верном ходе в масть: %v", err) + } +} + +func TestPlayCardMustTrumpWhenNoLedSuit(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(Nine, Clubs)}, + 1: {c(Jack, Hearts), c(Nine, Spades)}, + 2: {c(Nine, Diamonds)}, + }) + g.TrumpSet = true + g.TrumpSuit = Hearts + + if err := g.PlayCard(c(Nine, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Nine, Spades)); err != ErrThousandInvalidFollow { + t.Errorf("при отсутствии масти, но наличии козыря, обязан ходить козырем, получено: %v", err) + } + if err := g.PlayCard(c(Jack, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка при ходе козырем: %v", err) + } +} + +func TestTrickWinnerHighestOfLedSuit(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(Jack, Clubs)}, + 1: {c(King, Clubs)}, + 2: {c(Nine, Clubs)}, + }) + if err := g.PlayCard(c(Jack, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(King, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Nine, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.LeaderIdx != 1 { + t.Errorf("ожидался победитель взятки — игрок 1 (король треф), получено %d", g.LeaderIdx) + } + if g.Players[1].HandPoints != 6 { + t.Errorf("ожидалось 6 очков за взятку, получено %d", g.Players[1].HandPoints) + } +} + +func TestTenBeatsKingButLosesToAce(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(King, Clubs)}, + 1: {c(Ten, Clubs)}, + 2: {c(Ace, Clubs)}, + }) + if err := g.PlayCard(c(King, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Ace, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.LeaderIdx != 2 { + t.Errorf("туз должен побить и десятку, и короля; ожидался победитель 2, получено %d", g.LeaderIdx) + } +} + +func TestTrumpBeatsNonTrumpEvenIfLower(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(Ace, Clubs)}, + 1: {c(Nine, Hearts), c(Nine, Spades)}, + 2: {c(Queen, Diamonds), c(Nine, Diamonds)}, + }) + g.TrumpSet = true + g.TrumpSuit = Hearts + + if err := g.PlayCard(c(Ace, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Nine, Hearts)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Nine, Diamonds)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.LeaderIdx != 1 { + t.Errorf("младший козырь должен побить туза не в масть, ожидался победитель 1, получено %d", g.LeaderIdx) + } +} + +func TestMarriageDeclarationSetsTrumpAndScores(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(Nine, Clubs), c(King, Diamonds), c(Queen, Diamonds)}, + 1: {c(Jack, Clubs)}, + 2: {c(Ten, Clubs)}, + }) + if err := g.PlayCard(c(Nine, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Jack, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.PlayCard(c(Ten, Clubs)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.TricksPlayed != 1 { + t.Fatalf("ожидалась 1 сыгранная взятка, получено %d", g.TricksPlayed) + } + winner := g.LeaderIdx + + if winner == 0 { + if err := g.PlayCard(c(King, Diamonds)); err != nil { + t.Fatalf("неожиданная ошибка объявления марьяжа: %v", err) + } + if !g.TrumpSet || g.TrumpSuit != Diamonds { + t.Fatalf("бубновый марьяж должен был назначить козырь бубны") + } + if g.Players[0].HandPoints < 80 { + t.Errorf("ожидались очки марьяжа (80) в счёте игрока 0, получено %d", g.Players[0].HandPoints) + } + } +} + +func TestMarriageCannotBeDeclaredOnFirstTrick(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + setupThousandTrickPlay(g, 0, map[int][]Card{ + 0: {c(King, Diamonds), c(Queen, Diamonds), c(Nine, Clubs)}, + 1: {c(Nine, Hearts)}, + 2: {c(Nine, Spades)}, + }) + if err := g.PlayCard(c(King, Diamonds)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.TrumpSet { + t.Errorf("марьяж не должен объявляться на самой первой взятке раздачи") + } +} + +func TestHandScoringDeclarerMakesBid(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + g.DeclarerIdx = 0 + g.FinalBid = 100 + g.Players[0].HandPoints = 110 + g.Players[1].HandPoints = 5 + g.Players[2].HandPoints = 5 + g.resolveHandEnd() + + if !g.Result.Made { + t.Fatalf("объявивший торг должен был выполнить ставку") + } + if g.Result.ScoreDeltas[0] != 100 { + t.Errorf("выполнившему ставку засчитывается сама ставка (100), получено %d", g.Result.ScoreDeltas[0]) + } + if g.Players[0].Score != 100 { + t.Errorf("ожидался счёт 100, получено %d", g.Players[0].Score) + } +} + +func TestHandScoringDeclarerFailsBid(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + g.DeclarerIdx = 0 + g.FinalBid = 100 + g.Players[0].HandPoints = 60 + g.Players[1].HandPoints = 40 + g.Players[2].HandPoints = 20 + g.resolveHandEnd() + + if g.Result.Made { + t.Fatalf("не должен был выполнить ставку") + } + if g.Result.ScoreDeltas[0] != -100 { + t.Errorf("невыполнившему ставку списывается ставка (-100), получено %d", g.Result.ScoreDeltas[0]) + } + if g.Result.ScoreDeltas[1] != 40 || g.Result.ScoreDeltas[2] != 20 { + t.Errorf("соперники должны получить ровно свои очки: %v", g.Result.ScoreDeltas) + } +} + +func TestMatchOverAt1000(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + g.Players[0].Score = 920 + g.DeclarerIdx = 0 + g.FinalBid = 100 + g.Players[0].HandPoints = 100 + g.Players[1].HandPoints = 0 + g.Players[2].HandPoints = 0 + g.resolveHandEnd() + + if !g.Result.MatchOver { + t.Fatalf("партия должна была завершиться при достижении 1000 очков") + } + if g.Result.MatchWinner != 0 { + t.Errorf("ожидался победитель 0, получено %d", g.Result.MatchWinner) + } +} + +func TestNextHandRotatesDealer(t *testing.T) { + g := NewThousandGame([]string{"A", "B", "C"}, -1) + oldDealer := g.DealerIdx + g.NextHand() + if g.DealerIdx != (oldDealer+1)%3 { + t.Errorf("сдающий должен был смениться по кругу") + } + if g.Phase != ThousandPhaseBidding { + t.Errorf("новая раздача должна была начаться с торгов") + } +} diff --git a/thousand_tui.go b/thousand_tui.go new file mode 100644 index 0000000..13302b8 --- /dev/null +++ b/thousand_tui.go @@ -0,0 +1,412 @@ +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{"Вы"}, 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 = 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("бот %s: %w", m.game.Players[actor].Name, err)) + return m, nil + } + m.setInfo("%s сходил(а).", 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("Вы поставили %d.", 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("Вы пасовали.") + 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("Вы отдали %s.", 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("Вы сыграли %s.", 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("(нет карт)") + } + 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 + if i == m.humanIdx { + name += " (вы)" + } + role := "" + if m.game.Phase != ThousandPhaseBidding && i == m.game.DeclarerIdx { + role = " [торг]" + } + line := fmt.Sprintf("%s%-14s карт: %-2d очки за раздачу: %-4d счёт: %-5d%s", + 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("(взятка ещё не начата)") + } + 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 "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== 1000 ===")) + fmt.Fprintln(&b) + 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("[q] в меню")) + } else { + fmt.Fprintln(&b, dimStyle.Render("[n] новая раздача [q] в меню")) + } + return b.String() + } + + switch m.game.Phase { + case ThousandPhaseBidding: + fmt.Fprintf(&b, "Текущая максимальная ставка: %d (у %s)\n\n", m.game.HighBid, m.bidderName()) + case ThousandPhaseDiscard, ThousandPhaseTrick: + trumpStr := "ещё не назначен" + if m.game.TrumpSet { + trumpStr = suitName(m.game.TrumpSuit) + } + fmt.Fprintf(&b, "Ставка: %d (у %s) Козырь: %s\n", m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr) + if m.game.Phase == ThousandPhaseTrick { + fmt.Fprintln(&b, "Взятка:", m.renderTrick()) + } + fmt.Fprintln(&b) + } + + if m.actorForPhase() == m.humanIdx { + fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, m.renderHand()) + fmt.Fprintln(&b) + + switch m.game.Phase { + case ThousandPhaseBidding: + fmt.Fprintf(&b, "[↑→/kl] +5 [↓←/hj] -5 [enter] поставить %d [p] пас\n", m.bidAmount) + case ThousandPhaseDiscard: + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отдать карту следующему сопернику") + case ThousandPhaseTrick: + fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] сыграть карту (король/дама масти = объявить марьяж)") + } + fmt.Fprintln(&b, dimStyle.Render("[q] в меню")) + } else { + fmt.Fprintf(&b, "%s думает...\n", 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 "никого — торги ещё не начались" + } + return m.game.Players[m.game.HighBidderIdx].Name +} + +func suitName(s Suit) string { + switch s { + case Clubs: + return "трефы" + case Diamonds: + return "бубны" + case Hearts: + return "червы" + case Spades: + return "пики" + } + 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(fmt.Sprintf("%s выполнил(а) ставку %d!", declarerName, res.FinalBid))) + } else { + fmt.Fprintln(&b, errorStyle.Render(fmt.Sprintf("%s не выполнил(а) ставку %d.", declarerName, res.FinalBid))) + } + fmt.Fprintln(&b) + for i, p := range m.game.Players { + fmt.Fprintln(&b, fmt.Sprintf(" %-14s %+d очков за раздачу (итого: %d)", p.Name, res.ScoreDeltas[i], p.Score)) + } + if res.MatchOver { + fmt.Fprintln(&b) + fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Партия окончена! Победитель: %s", m.game.Players[res.MatchWinner].Name))) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/thousand_tui_test.go b/thousand_tui_test.go new file mode 100644 index 0000000..7e80193 --- /dev/null +++ b/thousand_tui_test.go @@ -0,0 +1,151 @@ +package main + +import "testing" + +func TestThousandModelDealsThreePlayers(t *testing.T) { + m := NewThousandModel() + if len(m.game.Players) != 3 { + t.Errorf("ожидалось 3 игрока, получено %d", len(m.game.Players)) + } +} + +func TestThousandModelBidViaKeys(t *testing.T) { + m := NewThousandModel() + m.game.CurrentBidderIdx = m.humanIdx + m.bidAmount = 100 + + next, _ := m.Update(key("enter")) + m = next.(ThousandModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.game.HighBid != 100 { + t.Fatalf("ожидалась ставка 100, получено %d", m.game.HighBid) + } +} + +func TestThousandModelPassRejectedForFirstBidder(t *testing.T) { + m := NewThousandModel() + m.game.CurrentBidderIdx = m.humanIdx + m.game.firstBidderIdx = m.humanIdx + m.game.bidsMade = 0 + + next, _ := m.Update(key("p")) + m = next.(ThousandModel) + if !m.isError { + t.Fatalf("первый ходящий не может пасовать, ошибка ожидалась") + } +} + +func TestThousandModelDiscardViaKeys(t *testing.T) { + m := NewThousandModel() + m.game.CurrentBidderIdx = m.humanIdx + m.bidAmount = 100 + next, _ := m.Update(key("enter")) + m = next.(ThousandModel) + if m.isError { + t.Fatalf("неожиданная ошибка ставки: %s", m.message) + } + if err := m.game.Pass(); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if err := m.game.Pass(); err != nil { + t.Fatalf("ошибка подготовки: %v", err) + } + if m.game.Phase != ThousandPhaseDiscard { + t.Fatalf("ожидалась фаза снoса") + } + if m.game.DeclarerIdx != m.humanIdx { + t.Fatalf("человек должен был стать declarer") + } + m.cursor = 0 + + next, _ = m.Update(key("enter")) + m = next.(ThousandModel) + if m.isError { + t.Fatalf("неожиданная ошибка при первом сносе: %s", m.message) + } + if len(m.game.Players[m.humanIdx].Hand) != 9 { + t.Fatalf("после первого сноса должно остаться 9 карт, получено %d", len(m.game.Players[m.humanIdx].Hand)) + } +} + +func TestThousandModelQReturnsToMenu(t *testing.T) { + m := NewThousandModel() + next, _ := m.Update(key("q")) + m = next.(ThousandModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +// TestThousandModelFullAutoPlaythrough прогоняет TUI-модель от +// начала до конца нескольких раздач, где "человек" отыгрывает свои +// решения через реальные нажатия клавиш (используя ту же логику, +// что и бот). +func TestThousandModelFullAutoPlaythrough(t *testing.T) { + m := NewThousandModel() + bot := ThousandBot{} + + const maxHands = 30 + for hand := 0; hand < maxHands; hand++ { + const maxSteps = 3000 + steps := 0 + for m.game.Phase != ThousandPhaseHandOver && steps < maxSteps { + steps++ + if m.actorForPhase() != m.humanIdx { + next, _ := m.Update(botMoveMsg{}) + m = next.(ThousandModel) + continue + } + + switch m.game.Phase { + case ThousandPhaseBidding: + if amount, ok := bot.DecideBid(m.game); ok { + m.bidAmount = amount + next, _ := m.Update(key("enter")) + m = next.(ThousandModel) + } else { + next, _ := m.Update(key("p")) + m = next.(ThousandModel) + } + case ThousandPhaseDiscard: + discards := bot.DecideDiscards(m.game.Players[m.humanIdx].Hand) + for _, card := range discards { + for i, hc := range m.currentHand() { + if hc == card { + m.cursor = i + break + } + } + next, _ := m.Update(key("enter")) + m = next.(ThousandModel) + } + case ThousandPhaseTrick: + card := bot.DecidePlay(m.game) + for i, hc := range m.currentHand() { + if hc == card { + m.cursor = i + break + } + } + next, _ := m.Update(key("enter")) + m = next.(ThousandModel) + } + } + if steps >= maxSteps { + t.Fatalf("раздача %d: не завершилась через TUI за %d шагов (фаза %v)", hand, maxSteps, m.game.Phase) + } + if m.game.Result == nil { + t.Fatalf("раздача %d: завершилась без результата", hand) + } + if m.game.Result.MatchOver { + return + } + next, _ := m.Update(key("n")) + m = next.(ThousandModel) + } +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..0f65902 --- /dev/null +++ b/tui.go @@ -0,0 +1,514 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// botMoveMsg сигнализирует о том, что пора сделать очередной ход +// бота — используется, чтобы ходы ботов были видны человеку с +// небольшой задержкой, а не проваливались мгновенно. +type botMoveMsg struct{} + +const botMoveDelay = 500 * time.Millisecond + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) + redCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + blackCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + cursorStyle = lipgloss.NewStyle().Reverse(true).Bold(true) + stagedStyle = lipgloss.NewStyle().Underline(true).Bold(true) + dimStyle = lipgloss.NewStyle().Faint(true) + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("87")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + turnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("228")).Bold(true) + winStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) +) + +// Model — состояние TUI поверх GameState. +type Model struct { + game *GameState + humanIdx int + bots map[int]BotStrategy + + cursor int + staged map[Card]bool + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewModel создаёт новую партию со стандартными денежными +// параметрами: человек — игрок 0, дальше три бота со случайными +// именами и случайной (не подсказывающей ничего) комбинацией +// уровней сложности. +func NewModel() Model { + return NewModelWithStakes(DefaultStartingCapital, DefaultAnte, DefaultTonkMultiplier) +} + +// NewModelWithStakes создаёт новую партию с явно заданными +// денежными параметрами (стартовый капитал, размер ставки, множитель +// банка при победе через Тонк) — используется экраном настроек +// перед началом игры. +func NewModelWithStakes(startingCapital, ante, tonkMultiplier int) Model { + botNames, botStrategies := newBotRoster(3) + names := append([]string{"Вы"}, botNames...) + + game := NewGameWithStakes(names, 0, startingCapital, ante, tonkMultiplier) + m := Model{ + game: game, + humanIdx: 0, + bots: map[int]BotStrategy{ + 1: botStrategies[0], + 2: botStrategies[1], + 3: botStrategies[2], + }, + staged: make(map[Card]bool), + } + return m +} + +func (m Model) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +// maybeScheduleBot возвращает команду, запускающую ход бота, если +// сейчас не очередь человека и раунд не завершён. +func (m Model) maybeScheduleBot() tea.Cmd { + if m.game.Phase == PhaseGameOver { + return nil + } + if m.game.CurrentPlayer == m.humanIdx { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *Model) resetSelectionState() { + m.staged = make(map[Card]bool) + if len(m.game.current().Hand) == 0 { + m.cursor = 0 + } else if m.cursor >= len(m.game.current().Hand) { + m.cursor = len(m.game.current().Hand) - 1 + } +} + +func (m *Model) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *Model) setError(err error) { + m.message = err.Error() + m.isError = true +} + +func (m Model) 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 Model) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Phase == PhaseGameOver { + return m, nil + } + idx := m.game.CurrentPlayer + bot, ok := m.bots[idx] + if !ok { + // на текущего игрока бот не назначен (это человек) — + // ничего не делаем, ждём ввода с клавиатуры + return m, nil + } + if err := bot.PlayTurn(m.game); err != nil { + m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + return m, nil + } + m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + if m.game.Phase != PhaseGameOver && m.game.CurrentPlayer == m.humanIdx { + m.resetSelectionState() + } + return m, m.maybeScheduleBot() +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + + if m.game.Phase == PhaseGameOver { + switch key { + case "n": + m.startNewRound() + return m, m.maybeScheduleBot() + case "q": + m.backToMenu = true + return m, nil + } + return m, nil + } + + if key == "q" { + m.backToMenu = true + return m, nil + } + + if m.game.CurrentPlayer != m.humanIdx { + // сейчас ходит бот — игнорируем ввод + return m, nil + } + + switch m.game.Phase { + case PhaseDraw: + return m.handleDrawKey(key) + case PhaseAction: + return m.handleActionKey(key) + } + return m, nil +} + +func (m Model) handleDrawKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "d": + if _, err := m.game.DrawFromDeck(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы взяли карту из колоды.") + m.resetSelectionState() + case "p": + card, err := m.game.DrawFromDiscard() + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы взяли из сброса: %s", card) + m.resetSelectionState() + } + return m, nil +} + +func (m Model) handleActionKey(key string) (tea.Model, tea.Cmd) { + hand := m.game.current().Hand + + switch key { + case "left", "h": + if m.cursor > 0 { + m.cursor-- + } + case "right", "l": + if m.cursor < len(hand)-1 { + m.cursor++ + } + case " ": + if len(hand) == 0 { + return m, nil + } + c := hand[m.cursor] + if m.staged[c] { + delete(m.staged, c) + } else { + m.staged[c] = true + } + case "m": + return m.handleLayMeld() + case "t": + res, err := m.game.DeclareTonk() + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы объявили Тонк!") + _ = res + return m, m.maybeScheduleBot() + case "enter": + if len(hand) == 0 { + return m, nil + } + card := hand[m.cursor] + if err := m.game.Discard(card); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Вы сбросили %s.", card) + if m.game.Phase != PhaseGameOver { + m.resetSelectionState() + } + return m, m.maybeScheduleBot() + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + idx := int(key[0] - '1') + return m.handleAddToMeld(idx) + } + return m, nil +} + +func (m Model) handleLayMeld() (tea.Model, tea.Cmd) { + if len(m.staged) < 3 { + m.setError(fmt.Errorf("выберите (пробел) минимум 3 карты для комбинации")) + return m, nil + } + cards := make([]Card, 0, len(m.staged)) + for c := range m.staged { + cards = append(cards, c) + } + if err := m.game.LayNewMeld(cards); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Комбинация выложена на стол.") + if m.game.Phase != PhaseGameOver { + m.resetSelectionState() + return m, nil + } + return m, m.maybeScheduleBot() +} + +func (m Model) handleAddToMeld(meldIdx int) (tea.Model, tea.Cmd) { + hand := m.game.current().Hand + if len(hand) == 0 { + return m, nil + } + if meldIdx < 0 || meldIdx >= len(m.game.Table) { + m.setError(fmt.Errorf("на столе нет комбинации №%d", meldIdx+1)) + return m, nil + } + card := hand[m.cursor] + if err := m.game.AddToMeld(meldIdx, card); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("Карта %s подложена к комбинации №%d.", card, meldIdx+1) + if m.game.Phase != PhaseGameOver { + m.resetSelectionState() + return m, nil + } + return m, m.maybeScheduleBot() +} + +// startNewRound начинает новый раунд, перенося капитал игроков из +// завершившегося раунда и заново собирая анте в банк. Перед этим +// проверяет, не остался ли кто-то без денег (см. checkBankruptcies) +// — такие игроки заменяются новыми ботами случайного уровня со +// стартовым капиталом. +func (m *Model) startNewRound() { + bankruptMsg := m.checkBankruptcies() + m.game = m.game.NextRound() + m.staged = make(map[Card]bool) + m.cursor = 0 + if bankruptMsg != "" { + m.setInfo("%s", bankruptMsg) + } else { + m.message = "" + m.isError = false + } +} + +// checkBankruptcies проверяет капитал каждого игрока после +// завершившегося раунда: у кого баланс опустился до нуля или ниже, +// тот выбывает и заменяется новым ботом — со случайным именем +// (по возможности не повторяющим тех, кто уже сидит за столом) и +// случайным уровнем сложности, который нигде не показывается. Если +// выбывает сам человек, его место тоже занимает бот, и humanIdx +// становится -1 (партия продолжается уже полностью автоматически, +// человек может понаблюдать или выйти в меню). Возвращает текстовое +// сообщение об произошедших заменах (пустая строка, если +// банкротств не было). +func (m *Model) checkBankruptcies() string { + var messages []string + for i, p := range m.game.Players { + if p.Balance > 0 { + continue + } + + used := make(map[string]bool, len(m.game.Players)) + for _, other := range m.game.Players { + used[other.Name] = true + } + newName := pickBotName(used) + bot, _ := randomBotStrategy() + wasHuman := p.IsHuman + oldName := p.Name + + p.Name = newName + p.Balance = m.game.StartingCapital + p.IsHuman = false + m.bots[i] = bot + + if wasHuman { + m.humanIdx = -1 + messages = append(messages, fmt.Sprintf( + "Вы обанкротились и выбыли! Ваше место занял новый игрок: %s. Дальше играют боты — можно наблюдать или [q] выйти в меню.", + newName)) + } else { + messages = append(messages, fmt.Sprintf( + "%s обанкротился(лась) и выбыл(а). За стол сел новый игрок: %s (капитал %d).", + oldName, newName, p.Balance)) + } + } + return strings.Join(messages, " ") +} + +func isRed(s Suit) bool { + return s == Hearts || s == Diamonds +} + +func renderCard(c Card) string { + style := blackCardStyle + if isRed(c.Suit) { + style = redCardStyle + } + return style.Render(c.String()) +} + +func (m Model) renderHand() string { + hand := m.game.current().Hand + if len(hand) == 0 { + return dimStyle.Render("(нет карт)") + } + parts := make([]string, len(hand)) + for i, c := range hand { + text := renderCard(c) + if m.staged[c] { + text = stagedStyle.Render(text) + } + if i == m.cursor { + text = cursorStyle.Render(c.String()) + } + parts[i] = text + } + return strings.Join(parts, " ") +} + +func (m Model) renderTable() string { + if len(m.game.Table) == 0 { + return dimStyle.Render("(стол пуст)") + } + var b strings.Builder + for i, meld := range m.game.Table { + kind := "Сет" + if meld.Kind == KindSequence { + kind = "Сиквенс" + } + cardsStr := make([]string, len(meld.Cards)) + for j, c := range meld.Cards { + cardsStr[j] = renderCard(c) + } + fmt.Fprintf(&b, " [%d] %s (%s): %s\n", i+1, kind, + m.game.Players[meld.Owner].Name, strings.Join(cardsStr, " ")) + } + return strings.TrimRight(b.String(), "\n") +} + +func (m Model) renderPlayers() string { + var b strings.Builder + for i, p := range m.game.Players { + marker := " " + if i == m.game.CurrentPlayer { + marker = "» " + } + name := p.Name + if i == m.humanIdx { + name += " (вы)" + } + line := fmt.Sprintf("%s%-24s карт: %-2d капитал: %d", marker, name, len(p.Hand), p.Balance) + if i == m.game.CurrentPlayer { + line = turnStyle.Render(line) + } + fmt.Fprintln(&b, line) + } + return strings.TrimRight(b.String(), "\n") +} + +func (m Model) View() string { + if m.quitting { + return "До встречи!\n" + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render("=== ТОНК ===")) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderPlayers()) + fmt.Fprintln(&b) + + top, ok := m.game.PeekDiscard() + topStr := "—" + if ok { + topStr = renderCard(top) + } + fmt.Fprintf(&b, "Колода: %d карт Сброс сверху: %s Банк раунда: %d (ставка: %d, Тонк ×%d)\n\n", + m.game.Deck.Len(), topStr, m.game.Pot, m.game.AnteAmount, m.game.TonkMultiplier) + + fmt.Fprintln(&b, "Комбинации на столе:") + fmt.Fprintln(&b, m.renderTable()) + fmt.Fprintln(&b) + + if m.game.Phase == PhaseGameOver { + fmt.Fprintln(&b, m.renderRoundResult()) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + return b.String() + } + + if m.game.CurrentPlayer == m.humanIdx { + fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, m.renderHand()) + fmt.Fprintf(&b, "Вес руки: %d\n\n", HandValue(m.game.current().Hand)) + + switch m.game.Phase { + case PhaseDraw: + fmt.Fprintln(&b, "Ваш ход. [d] взять из колоды [p] взять из сброса ("+topStr+") [q] в меню") + case PhaseAction: + tonkHint := "" + if CanDeclareTonk(m.game.current().Hand, m.game.TonkThreshold) { + tonkHint = winStyle.Render(" [t] Тонк доступен!") + } + fmt.Fprintln(&b, "[←→/hl] выбор карты [space] отметить для комбинации [m] выложить комбинацию") + fmt.Fprintln(&b, "[1-9] подложить текущую карту к комбинации №N [enter] сбросить текущую карту"+tonkHint) + } + } else { + fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayer].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 Model) renderRoundResult() string { + res := m.game.Result + var b strings.Builder + switch { + case res.DeadRound: + fmt.Fprintln(&b, errorStyle.Render(fmt.Sprintf("Раунд закончился вничью: колода и сброс исчерпаны. Банк (%d) возвращён игрокам.", res.Pot))) + case res.TonkCall: + fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s объявил(а) Тонк и забрал(а) банк ×%d (%d)!", + m.game.Players[res.Winner].Name, m.game.TonkMultiplier, res.Pot*m.game.TonkMultiplier))) + default: + fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s избавился(лась) от всех карт и забрал(а) банк (%d)!", + m.game.Players[res.Winner].Name, res.Pot))) + } + for i, p := range m.game.Players { + fmt.Fprintf(&b, " %-24s изменение капитала: %+d (итого: %d)\n", p.Name, res.BalanceDeltas[i], p.Balance) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..f46b7a8 --- /dev/null +++ b/tui_test.go @@ -0,0 +1,229 @@ +package main + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// key — маленький хелпер для конструирования tea.KeyMsg, которые +// соответствуют строкам, разбираемым в handleActionKey/handleDrawKey. +func key(s string) tea.KeyMsg { + switch s { + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case " ": + return tea.KeyMsg{Type: tea.KeySpace} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func TestTUIDrawAndDiscardCycle(t *testing.T) { + m := NewModel() + // делаем раунд предсказуемым: у человека маленькая рука без + // готовых комбинаций + m.game.Players[0].Hand = []Card{c(Two, Clubs), c(Nine, Hearts), c(King, Spades)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseDraw + + next, _ := m.Update(key("d")) + m = next.(Model) + if m.game.Phase != PhaseAction { + t.Fatalf("после взятия карты ожидалась фаза PhaseAction, получена %v", m.game.Phase) + } + if len(m.game.current().Hand) != 4 { + t.Fatalf("ожидалось 4 карты после взятия, получено %d", len(m.game.current().Hand)) + } + + next, _ = m.Update(key("enter")) + m = next.(Model) + if m.game.CurrentPlayer == m.humanIdx && m.game.Phase != PhaseGameOver { + t.Fatalf("после сброса ход должен был перейти к боту") + } +} + +func TestTUILayMeldViaKeys(t *testing.T) { + m := NewModel() + m.game.Players[0].Hand = []Card{ + c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades), c(Two, Diamonds), + } + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseAction + + // отмечаем три семёрки пробелом, двигая курсор + for i := 0; i < 3; i++ { + next, _ := m.Update(key(" ")) + m = next.(Model) + if i < 2 { + next, _ = m.Update(key("right")) + m = next.(Model) + } + } + if len(m.staged) != 3 { + t.Fatalf("ожидалось 3 отмеченные карты, получено %d", len(m.staged)) + } + + next, _ := m.Update(key("m")) + m = next.(Model) + if m.isError { + t.Fatalf("неожиданная ошибка при выкладке комбинации: %s", m.message) + } + if len(m.game.Table) != 1 { + t.Fatalf("ожидалась 1 комбинация на столе, получено %d", len(m.game.Table)) + } + if len(m.game.current().Hand) != 1 { + t.Fatalf("в руке должна была остаться 1 карта, получено %d", len(m.game.current().Hand)) + } + if len(m.staged) != 0 { + t.Fatalf("отметки должны сброситься после выкладки комбинации") + } +} + +func TestTUILayMeldRejectsFewerThanThree(t *testing.T) { + m := NewModel() + m.game.Players[0].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Two, Diamonds)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseAction + + next, _ := m.Update(key(" ")) + m = next.(Model) + next, _ = m.Update(key("m")) + m = next.(Model) + if !m.isError { + t.Fatalf("ожидалась ошибка при попытке выложить меньше 3 карт") + } +} + +func TestTUIAddToMeld(t *testing.T) { + m := NewModel() + // комбинацию на столе готовит бот (игрок 1) — напрямую через + // GameState, это лишь подготовка сцены для теста + m.game.Players[1].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades)} + m.game.CurrentPlayer = 1 + m.game.Phase = PhaseAction + if err := m.game.LayNewMeld(m.game.Players[1].Hand); err != nil { + t.Fatalf("не удалось подготовить стол: %v", err) + } + + // а вот подкладывает карту уже человек — через реальный путь + // TUI (Update + нажатие клавиши), именно это здесь и проверяем + m.game.Players[0].Hand = []Card{c(Seven, Diamonds)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseAction + m.cursor = 0 + next, _ := m.Update(key("1")) + m = next.(Model) + if m.isError { + t.Fatalf("неожиданная ошибка подкладки: %s", m.message) + } + if len(m.game.Table[0].Cards) != 4 { + t.Fatalf("ожидалось 4 карты в комбинации, получено %d", len(m.game.Table[0].Cards)) + } +} + +func TestTUIDeclareTonk(t *testing.T) { + m := NewModel() + m.game.Players[0].Hand = []Card{c(Ace, Clubs), c(Two, Hearts)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseAction + + next, _ := m.Update(key("t")) + m = next.(Model) + if m.isError { + t.Fatalf("неожиданная ошибка объявления Тонка: %s", m.message) + } + if m.game.Phase != PhaseGameOver || !m.game.Result.TonkCall { + t.Fatalf("раунд должен был завершиться объявлением Тонка") + } +} + +func TestTUINewRoundCarriesOverBalance(t *testing.T) { + m := NewModel() + m.game.Players[1].Hand = []Card{c(Ace, Clubs)} + m.game.CurrentPlayer = 1 + m.game.Phase = PhaseAction + if err := m.game.Discard(c(Ace, Clubs)); err != nil { + t.Fatalf("не удалось завершить раунд для теста: %v", err) + } + if m.game.Phase != PhaseGameOver { + t.Fatalf("раунд должен был завершиться") + } + balanceAfterRound := m.game.Players[0].Balance + + next, _ := m.Update(key("n")) + m = next.(Model) + if m.game.Phase == PhaseGameOver { + t.Fatalf("новый раунд должен был начаться") + } + // капитал переносится, но в новом раунде заново списывается + // анте — итоговый капитал должен быть меньше на размер ставки + want := balanceAfterRound - m.game.AnteAmount + if m.game.Players[0].Balance != want { + t.Fatalf("капитал должен был перенестись с учётом нового анте: ожидалось %d, получено %d", + want, m.game.Players[0].Balance) + } +} + +// TestTUIFullAutoPlaythrough прогоняет TUI-модель от начала до +// конца, но подменяет человека ботом на уровне вызовов Update: +// вместо реальных нажатий клавиш скрипт запрашивает у SimpleBot +// решение и переводит его в нажатия клавиш. Так мы проверяем, что +// весь путь через Update (а не только через GameState напрямую) +// стабильно доводит партию до конца без паники и зависаний. +func TestTUIFullAutoPlaythrough(t *testing.T) { + m := NewModel() + + const maxSteps = 2000 + steps := 0 + for m.game.Phase != PhaseGameOver && steps < maxSteps { + steps++ + if m.game.CurrentPlayer != m.humanIdx { + next, _ := m.Update(botMoveMsg{}) + m = next.(Model) + continue + } + + // "человек" в этом тесте играет как простой бот, но через + // реальные нажатия клавиш, а не напрямую через GameState + switch m.game.Phase { + case PhaseDraw: + top, ok := m.game.PeekDiscard() + k := "d" + if ok && wouldFormMeld(m.game.current().Hand, top) { + k = "p" + } + next, _ := m.Update(key(k)) + m = next.(Model) + case PhaseAction: + discard := chooseDiscardSimple(m.game.current().Hand) + for i, cardInHand := range m.game.current().Hand { + if cardInHand == discard { + m.cursor = i + break + } + } + next, _ := m.Update(key("enter")) + m = next.(Model) + } + } + + if steps >= maxSteps { + t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) + } + if m.game.Result == nil { + t.Fatalf("партия завершилась без результата") + } +}