First version.

This commit is contained in:
Magnus Root 2026-07-04 13:51:04 +03:00
parent 11bf4e80b3
commit 6dbf223cf5
57 changed files with 13661 additions and 0 deletions

46
.gitignore vendored Executable file
View file

@ -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

113
README.md Normal file
View file

@ -0,0 +1,113 @@
# Go Games Collection
Коллекция консольных карточных игр и настольных игр на Go с текстовым интерфейсом (TUI) на [Bubble Tea](https://github.com/charmbracelet/bubbletea). Играете против ботов (кроме пасьянса — он на одного) прямо в терминале.
```
____ ___ ____ _ __ __ _____ ____
/ ___|/ _ \ / ___| / \ | \/ | ____/ ___|
| | _| | | | | | _ / _ \ | |\/| | _| \___ \
| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) |
\____|\___/ \____/_/ \_\_| |_|_____|____/
____ ___ _ _ _____ ____ _____ ___ ___ _ _
/ ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | |
| | | | | | | | | | _|| | | | | | | | | \| |
| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ |
\____\___/|_____|_____|_____\____| |_| |___\___/|_| \_|
```
## Игры в коллекции
| Игра | Игроков | Особенности |
|---|---|---|
| **Тонк (Tonk)** | 1 человек + 3 бота | Денежная система: капитал, ставка, банкротство и замена бота |
| **Дурак (Durak)** | 26 | Полноценное подкидывание любым игроком (не только атакующим) |
| **Блэкджек (Blackjack)** | 16 (можно соло) | Каждый играет против дилера отдельно, hit/stand/double |
| **101** | 26 | Избавься от карт первым; выбывание по достижении 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 полуходов без взятий.

100
bankruptcy_test.go Normal file
View file

@ -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)
}
}
}

16
banner.go Normal file
View file

@ -0,0 +1,16 @@
package main
// asciiBanner — ASCII-заголовок "GO GAMES COLLECTION", сгенерирован figlet (шрифт standard).
const asciiBanner = `
____ ___ ____ _ __ __ _____ ____
/ ___|/ _ \ / ___| / \ | \/ | ____/ ___|
| | _| | | | | | _ / _ \ | |\/| | _| \___ \
| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) |
\____|\___/ \____/_/ \_\_| |_|_____|____/
____ ___ _ _ _____ ____ _____ ___ ___ _ _
/ ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | |
| | | | | | | | | | _|| | | | | | | | | \| |
| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ |
\____\___/|_____|_____|_____\____| |_| |___\___/|_| \_|
`

39
blackjack_bot.go Normal file
View file

@ -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()
}
}

340
blackjack_game.go Normal file
View file

@ -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)
}

358
blackjack_game_test.go Normal file
View file

@ -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))
}
}
}
}

276
blackjack_tui.go Normal file
View file

@ -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")
}

155
blackjack_tui_test.go Normal file
View file

@ -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)
}
}
}

274
bot.go Normal file
View file

@ -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
}

133
bot_heuristics.go Normal file
View file

@ -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
}
}
}

87
bot_naming_test.go Normal file
View file

@ -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
}

90
card.go Normal file
View file

@ -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()
}

217
checkers_bot.go Normal file
View file

@ -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)
}

65
checkers_bot_test.go Normal file
View file

@ -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("обе шашки должны были быть взяты")
}
}

327
checkers_game.go Normal file
View file

@ -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()}
}
}

235
checkers_game_test.go Normal file
View file

@ -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)
}
}

295
checkers_tui.go Normal file
View file

@ -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()
}

134
checkers_tui_test.go Normal file
View file

@ -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)
}
}

65
deck.go Normal file
View file

@ -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
}

177
durak_bot.go Normal file
View file

@ -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
}

99
durak_bot_test.go Normal file
View file

@ -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
}
}
}
}

34
durak_deck.go Normal file
View file

@ -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
}

538
durak_game.go Normal file
View file

@ -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
}
}

427
durak_game_test.go Normal file
View file

@ -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("при исчерпанном лимите атаки фаза подкидывания не должна была наступить")
}
}

410
durak_tui.go Normal file
View file

@ -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))
}

241
durak_tui_test.go Normal file
View file

@ -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)
}
}
}

497
game.go Normal file
View file

@ -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())
}

244
game_test.go Normal file
View file

@ -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)
}
}
}
}

32
go.mod Normal file
View file

@ -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
)

35
go.sum Normal file
View file

@ -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=

101
klondike_bot_test.go Normal file
View file

@ -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)
}
}
}
}

273
klondike_game.go Normal file
View file

@ -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
}

253
klondike_game_test.go Normal file
View file

@ -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 фундамента на короле должны означать победу")
}
}

306
klondike_tui.go Normal file
View file

@ -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()
}

161
klondike_tui_test.go Normal file
View file

@ -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("новая партия не должна была начаться выигранной")
}
}

269
main.go Normal file
View file

@ -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("матч не завершился за отведённое число раздач")
}

178
meld.go Normal file
View file

@ -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
}

130
meld_test.go Normal file
View file

@ -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))
}
}

772
menu.go Normal file
View file

@ -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()
}

748
menu_test.go Normal file
View file

@ -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{}
}

102
oneohone_bot.go Normal file
View file

@ -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
}

80
oneohone_bot_test.go Normal file
View file

@ -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)
}
}
}
}

435
oneohone_game.go Normal file
View file

@ -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()
}

352
oneohone_game_test.go Normal file
View file

@ -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))
}
}

306
oneohone_tui.go Normal file
View file

@ -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")
}

118
oneohone_tui_test.go Normal file
View file

@ -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)
}
}
}

51
player.go Normal file
View file

@ -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
}

440
rules.go Normal file
View file

@ -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 выход
`

221
thousand_bot.go Normal file
View file

@ -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
}

59
thousand_bot_test.go Normal file
View file

@ -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)
}
}
}

502
thousand_game.go Normal file
View file

@ -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()
}

399
thousand_game_test.go Normal file
View file

@ -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("новая раздача должна была начаться с торгов")
}
}

412
thousand_tui.go Normal file
View file

@ -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")
}

151
thousand_tui_test.go Normal file
View file

@ -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)
}
}

514
tui.go Normal file
View file

@ -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")
}

229
tui_test.go Normal file
View file

@ -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("партия завершилась без результата")
}
}