First version.
This commit is contained in:
parent
11bf4e80b3
commit
6dbf223cf5
57 changed files with 13661 additions and 0 deletions
46
.gitignore
vendored
Executable file
46
.gitignore
vendored
Executable 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
113
README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Go Games Collection
|
||||
|
||||
Коллекция консольных карточных игр и настольных игр на Go с текстовым интерфейсом (TUI) на [Bubble Tea](https://github.com/charmbracelet/bubbletea). Играете против ботов (кроме пасьянса — он на одного) прямо в терминале.
|
||||
|
||||
```
|
||||
____ ___ ____ _ __ __ _____ ____
|
||||
/ ___|/ _ \ / ___| / \ | \/ | ____/ ___|
|
||||
| | _| | | | | | _ / _ \ | |\/| | _| \___ \
|
||||
| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) |
|
||||
\____|\___/ \____/_/ \_\_| |_|_____|____/
|
||||
____ ___ _ _ _____ ____ _____ ___ ___ _ _
|
||||
/ ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | |
|
||||
| | | | | | | | | | _|| | | | | | | | | \| |
|
||||
| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ |
|
||||
\____\___/|_____|_____|_____\____| |_| |___\___/|_| \_|
|
||||
```
|
||||
|
||||
## Игры в коллекции
|
||||
|
||||
| Игра | Игроков | Особенности |
|
||||
|---|---|---|
|
||||
| **Тонк (Tonk)** | 1 человек + 3 бота | Денежная система: капитал, ставка, банкротство и замена бота |
|
||||
| **Дурак (Durak)** | 2–6 | Полноценное подкидывание любым игроком (не только атакующим) |
|
||||
| **Блэкджек (Blackjack)** | 1–6 (можно соло) | Каждый играет против дилера отдельно, hit/stand/double |
|
||||
| **101** | 2–6 | Избавься от карт первым; выбывание по достижении 101 очка |
|
||||
| **1000 (Тысяча)** | строго 3 | Торги, прикуп, взятки и марьяжи с динамическим козырем |
|
||||
| **Косынка (Klondike)** | 1 (без ботов) | Классический пасьянс с отменой хода |
|
||||
| **Шашки (Checkers)** | 1 человек + бот | Русские шашки, бот на минимаксе с 2 уровнями сложности |
|
||||
|
||||
Каждая игра — со своим набором специально подобранных упрощений там, где у оригинала слишком много вариантов правил (например, «1000» реализована только на троих, а Дурак — без «тузового марьяжа» и бочки). Полный список правил и упрощений — в разделе «Правила» самого приложения или через `--help`.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуется Go 1.22+.
|
||||
|
||||
```bash
|
||||
go run . # открыть меню коллекции
|
||||
go run . --help # показать правила всех игр, не заходя в TUI
|
||||
go run . -sim # headless-симуляция ботов Тонка (для проверки баланса)
|
||||
go run . -sim-durak # то же для Дурака
|
||||
go run . -sim-blackjack # то же для Блэкджека
|
||||
go run . -sim-101 # то же для 101
|
||||
go run . -sim-1000 # то же для Тысячи
|
||||
```
|
||||
|
||||
Или собрать бинарник:
|
||||
|
||||
```bash
|
||||
go build -o games .
|
||||
./games
|
||||
```
|
||||
|
||||
## Общее управление в меню
|
||||
|
||||
- `↑↓` / `k j` — навигация по пунктам меню
|
||||
- `enter` — выбрать / подтвердить
|
||||
- `esc` / `q` — назад
|
||||
- Внутри игры `q` всегда возвращает в меню (не закрывает программу); `ctrl+c` закрывает приложение целиком
|
||||
|
||||
У части игр перед стартом есть экран настроек: ставка и капитал (Тонк), число игроков (Дурак, 101), число игроков + ставка (Блэкджек), уровень сложности бота (Шашки).
|
||||
|
||||
Управление в конкретной игре не дублируется здесь — оно всегда показано внизу экрана самой игры, а также доступно целиком через пункт «Правила» в меню или `--help`.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
Каждая игра — самостоятельный набор из 3 файлов (движок / бот / TUI) плюс тесты; общая для всех инфраструктура (меню, карты, колода) вынесена отдельно.
|
||||
|
||||
```
|
||||
card.go, deck.go — общие типы карт и стандартная колода на 52 карты
|
||||
menu.go, banner.go — главное меню, ASCII-баннер, экраны настроек игр
|
||||
rules.go — тексты правил всех игр (для TUI и --help)
|
||||
main.go — точка входа, флаги -sim-*, --help
|
||||
|
||||
game.go, bot.go, — Тонк: движок, боты трёх уровней сложности,
|
||||
bot_heuristics.go, банкротство и замена бота, TUI
|
||||
tui.go, player.go,
|
||||
meld.go
|
||||
|
||||
durak_*.go — Дурак: движок с полным подкидыванием, бот, TUI
|
||||
|
||||
blackjack_*.go — Блэкджек: движок дилера, бот, TUI
|
||||
|
||||
oneohone_*.go — 101: движок, бот, TUI
|
||||
|
||||
thousand_*.go — 1000: движок торгов/взяток/марьяжей, бот, TUI
|
||||
|
||||
klondike_*.go — Косынка: движок пасьянса с отменой хода, TUI
|
||||
(ботов нет — играть некому, кроме человека)
|
||||
|
||||
checkers_*.go — Шашки: движок русских шашек, бот на минимаксе, TUI
|
||||
|
||||
*_test.go — тесты (юнит-тесты правил + стресс-симуляции
|
||||
полных партий ботами)
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
go test ./... # весь пакет
|
||||
go test ./... -v # подробный вывод по каждому тесту
|
||||
```
|
||||
|
||||
В пакете 215 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой или марьяжа на первой взятке) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте.
|
||||
|
||||
## Известные упрощения
|
||||
|
||||
У традиционных карточных игр обычно много региональных вариантов правил. Там, где выбор был сделан осознанно (а не по ошибке), это явно прокомментировано в соответствующем `*_game.go` и продублировано в тексте правил игры. Коротко:
|
||||
|
||||
- **Дурак** — полные правила (подкидывать может любой игрок, не только атакующий).
|
||||
- **101** — туз пропускает ход, 6/7/пиковый король дают штрафные карты; десятка не меняет направление хода.
|
||||
- **1000** — только на 3 игроков; нет бочки, золотого кона, самосвала, тузового марьяжа и игры втёмную.
|
||||
- **Косынка** — добор по одной карте, неограниченные пересдачи; возврата карт с фундамента в игру нет.
|
||||
- **Шашки** — русские правила (взятие в любом направлении простой шашкой, «летающая» дамка), без официальных 15/30-ходовых правил ничьей — вместо них простой лимит в 60 полуходов без взятий.
|
||||
100
bankruptcy_test.go
Normal file
100
bankruptcy_test.go
Normal 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
16
banner.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package main
|
||||
|
||||
// asciiBanner — ASCII-заголовок "GO GAMES COLLECTION", сгенерирован figlet (шрифт standard).
|
||||
const asciiBanner = `
|
||||
____ ___ ____ _ __ __ _____ ____
|
||||
/ ___|/ _ \ / ___| / \ | \/ | ____/ ___|
|
||||
| | _| | | | | | _ / _ \ | |\/| | _| \___ \
|
||||
| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) |
|
||||
\____|\___/ \____/_/ \_\_| |_|_____|____/
|
||||
|
||||
____ ___ _ _ _____ ____ _____ ___ ___ _ _
|
||||
/ ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | |
|
||||
| | | | | | | | | | _|| | | | | | | | | \| |
|
||||
| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ |
|
||||
\____\___/|_____|_____|_____\____| |_| |___\___/|_| \_|
|
||||
`
|
||||
39
blackjack_bot.go
Normal file
39
blackjack_bot.go
Normal 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
340
blackjack_game.go
Normal 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
358
blackjack_game_test.go
Normal 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
276
blackjack_tui.go
Normal 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
155
blackjack_tui_test.go
Normal 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
274
bot.go
Normal 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
133
bot_heuristics.go
Normal 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
87
bot_naming_test.go
Normal 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
90
card.go
Normal 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
217
checkers_bot.go
Normal 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
65
checkers_bot_test.go
Normal 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
327
checkers_game.go
Normal 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
235
checkers_game_test.go
Normal 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
295
checkers_tui.go
Normal 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
134
checkers_tui_test.go
Normal 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
65
deck.go
Normal 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
177
durak_bot.go
Normal 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
99
durak_bot_test.go
Normal 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
34
durak_deck.go
Normal 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
538
durak_game.go
Normal 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
427
durak_game_test.go
Normal 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
410
durak_tui.go
Normal 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
241
durak_tui_test.go
Normal 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
497
game.go
Normal 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
244
game_test.go
Normal 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
32
go.mod
Normal 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
35
go.sum
Normal 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
101
klondike_bot_test.go
Normal 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
273
klondike_game.go
Normal 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
253
klondike_game_test.go
Normal 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
306
klondike_tui.go
Normal 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
161
klondike_tui_test.go
Normal 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
269
main.go
Normal 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
178
meld.go
Normal 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
130
meld_test.go
Normal 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
772
menu.go
Normal 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
748
menu_test.go
Normal 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
102
oneohone_bot.go
Normal 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
80
oneohone_bot_test.go
Normal 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
435
oneohone_game.go
Normal 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
352
oneohone_game_test.go
Normal 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
306
oneohone_tui.go
Normal 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
118
oneohone_tui_test.go
Normal 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
51
player.go
Normal 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
440
rules.go
Normal 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
221
thousand_bot.go
Normal 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
59
thousand_bot_test.go
Normal 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
502
thousand_game.go
Normal 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
399
thousand_game_test.go
Normal 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
412
thousand_tui.go
Normal 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
151
thousand_tui_test.go
Normal 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
514
tui.go
Normal 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
229
tui_test.go
Normal 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("партия завершилась без результата")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue