This commit is contained in:
Magnus Root 2026-07-10 09:12:06 +03:00
parent d6f1d11814
commit 9353ab527a
41 changed files with 3608 additions and 263 deletions

View file

@ -37,6 +37,7 @@ A collection of console card and board games in Go with a text UI (TUI) built on
| **Nardy (Backgammon)** | 1 human + bot | Choice of short or long nardy rules, 3 bot difficulty levels |
| **The Royal Game of Ur** | 1 human + bot | One of the oldest known board games (~2600 BCE), Finkel's rule reconstruction, 3 difficulty levels |
| **Senet** | 1 human + bot | Ancient Egyptian game (~3500 BCE), a consolidated rule reconstruction, 3 difficulty levels |
| **Texas Hold'em** | 2-6 players | Full No-Limit elimination tournament with side pots; each bot's difficulty is assigned randomly |
Each game comes with its own set of deliberately chosen simplifications where the original has too many regional rule variants (for example, "1000" is only implemented for 3 players, and Durak doesn't include the "ace marriage" or barrel rule). The full list of rules and simplifications is in the app's own "Rules" section or via `--help`.
@ -140,7 +141,7 @@ go test ./... # the whole package
go test ./... -v # verbose output for every test
```
The package has 726 tests: unit tests for game logic (dealing, moves, scoring, edge cases like king captures, a marriage on the first trick, castling out of check or a pinned piece, an actual ko diagram in Go, chording in Minesweeper, full playability of all 8 Wayfarer quests) and separate stress tests that run hundreds to thousands of simulated bot games in a row — these are specifically written to catch rare hangs, card loss/duplication, and other subtle bugs that aren't always visible in a single manual test.
The package has 786 tests: unit tests for game logic (dealing, moves, scoring, edge cases like king captures, a marriage on the first trick, castling out of check or a pinned piece, an actual ko diagram in Go, chording in Minesweeper, full playability of all 8 Wayfarer quests) and separate stress tests that run hundreds to thousands of simulated bot games in a row — these are specifically written to catch rare hangs, card loss/duplication, and other subtle bugs that aren't always visible in a single manual test.
## Known simplifications
@ -159,3 +160,4 @@ Traditional card games usually have many regional rule variants. Where a choice
- **Nardy** — one specific, widely-used ruleset is implemented for each variant (short and long); the doubling cube isn't implemented, a special rule allowing two checkers off the head on the first move with certain doubles (found in some long nardy rulesets) isn't implemented, and the restriction against fully blocking all 15 of the opponent's checkers with a six-point prime isn't enforced as a separate check either — regions disagree even on this.
- **The Royal Game of Ur** — implements Irving Finkel's reconstruction; other historians (Masters, Murray) propose a different path around the board — a competing reconstruction, not the single correct one.
- **Senet** — the original rules haven't survived in any source; one consolidated reconstruction (drawing on Kendall and Jéquier) is implemented out of several that exist. The "overshoot bounces back" rule for squares 28/29/30 isn't implemented — sources disagree on this detail, and the simpler version was chosen (missing the exact roll just doesn't let the piece bear off).
- **Texas Hold'em** — blinds are fixed (they don't increase over the tournament); under strict tournament rules a short all-in raise for less than a full raise shouldn't reopen betting for players who've already acted — this game doesn't draw that distinction. The bots don't use an honest GTO solver — hand-strength estimation via Monte Carlo simulation plus pot odds, not perfect play.

View file

@ -37,6 +37,7 @@
| **Нарды (Nardy)** | 1 человек + бот | Короткие или длинные нарды на выбор, 3 уровня сложности бота |
| **Игра Ур (Royal Game of Ur)** | 1 человек + бот | Одна из древнейших настольных игр (~2600 до н.э.), реконструкция правил Финкеля, 3 уровня сложности |
| **Сенет (Senet)** | 1 человек + бот | Древнеегипетская игра (~3500 до н.э.), консолидированная реконструкция правил, 3 уровня сложности |
| **Техасский Холдем (Texas Hold'em)** | 2-6 игроков | Полноценный турнир No-Limit с выбыванием и сайд-потами; уровень каждого бота назначается случайно |
Каждая игра — со своим набором специально подобранных упрощений там, где у оригинала слишком много вариантов правил (например, «1000» реализована только на троих, а Дурак — без «тузового марьяжа» и бочки). Полный список правил и упрощений — в разделе «Правила» самого приложения или через `--help`.
@ -138,7 +139,7 @@ go test ./... # весь пакет
go test ./... -v # подробный вывод по каждому тесту
```
В пакете 726 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой, марьяжа на первой взятке, рокировки под шахом или связанной фигуры, реальной диаграммы ко в Го, хорды в Сапёре, полной прохождимости всех 8 квестов Странника) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте.
В пакете 786 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой, марьяжа на первой взятке, рокировки под шахом или связанной фигуры, реальной диаграммы ко в Го, хорды в Сапёре, полной прохождимости всех 8 квестов Странника) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте.
## Известные упрощения
@ -157,3 +158,4 @@ go test ./... -v # подробный вывод по каждому те
- **Нарды** — реализован один конкретный, распространённый набор правил каждого варианта (короткие и длинные); куб удвоения ставок не реализован, особое правило снятия двух шашек с головы первым ходом при некоторых дублях (встречается в части сводов правил длинных нард) не реализовано, ограничение на полную блокировку всех 15 шашек соперника шестью подряд занятыми пунктами тоже не реализовано отдельной проверкой — регионы расходятся даже в этом.
- **Игра Ур** — реализована реконструкция Ирвинга Финкеля; другие историки (Мастерс, Мюррей) предлагают иной путь по доске — конкурирующая реконструкция, не единственно верная.
- **Сенет** — подлинные правила игры не сохранились ни в одном источнике; реализована одна консолидированная реконструкция (по Кендаллу и Жекье) из нескольких существующих. Правило "перелёт с отскоком назад" на клетках 28/29/30 не реализовано — источники расходятся в этом нюансе, выбран более простой вариант (промах просто не даёт снять шашку).
- **Техасский Холдем** — блайнды фиксированы (без роста по ходу турнира); короткий олл-ин рейзом меньше полного размера предыдущего рейза, по строгим турнирным правилам, не должен заново открывать торги уже походившим игрокам — эта игра такого разграничения не делает. Боты не используют честного GTO-решателя — оценка силы руки методом Монте-Карло плюс шансы банка, не идеальная игра.

View file

@ -73,6 +73,12 @@ type BlackjackGameState struct {
StartingCapital int
Result *BlackjackResult
// RoundBetReduced[i] == true, если у игрока i не хватило баланса
// на полную ставку Bet в этом раунде — тогда он честно ставит
// всё, что у него осталось (в том числе 0, если денег нет вовсе),
// но никогда не уходит в минус.
RoundBetReduced []bool
preRoundBalance []int
}
@ -154,10 +160,19 @@ func NewBlackjackRound(names []string, humanIdx int, startingCapital, bet int, b
}
g.preRoundBalance = make([]int, len(players))
g.RoundBetReduced = make([]bool, len(players))
for i, p := range players {
g.preRoundBalance[i] = p.Balance
p.Balance -= bet
p.Bet = bet
roundBet := bet
if roundBet > p.Balance {
roundBet = p.Balance
g.RoundBetReduced[i] = true
}
if roundBet < 0 {
roundBet = 0
}
p.Balance -= roundBet
p.Bet = roundBet
}
// раздача: по 2 карты каждому игроку и дилеру, по одной за проход

View file

@ -356,3 +356,64 @@ func TestBlackjackFullSimulation(t *testing.T) {
}
}
}
func TestNewBlackjackRoundClampsInsufficientBet(t *testing.T) {
g := NewBlackjackRound([]string{"Вы", "Бот"}, 0, 100, 10, []int{5, 100})
if g.Players[0].Bet != 5 {
t.Errorf("ставка должна была урезаться до всего оставшегося баланса (5), получено %d", g.Players[0].Bet)
}
if g.Players[0].Balance != 0 {
t.Errorf("после урезанной ставки баланс должен был стать ровно 0, получено %d", g.Players[0].Balance)
}
if !g.RoundBetReduced[0] {
t.Errorf("ожидался флаг RoundBetReduced[0]=true")
}
if g.RoundBetReduced[1] {
t.Errorf("у игрока с достаточным балансом флаг урезания ставки быть не должно")
}
}
func TestNewBlackjackRoundNeverGoesNegative(t *testing.T) {
g := NewBlackjackRound([]string{"Вы"}, 0, 100, 10, []int{0})
if g.Players[0].Balance < 0 {
t.Fatalf("баланс не должен был уйти в минус, получено %d", g.Players[0].Balance)
}
if g.Players[0].Balance != 0 {
t.Errorf("баланс при отсутствии денег должен был остаться 0, получено %d", g.Players[0].Balance)
}
if g.Players[0].Bet != 0 {
t.Errorf("ставка при отсутствии денег должна была стать 0, получено %d", g.Players[0].Bet)
}
}
func TestBlackjackZeroBetRoundNeverChangesBalance(t *testing.T) {
g := NewBlackjackRound([]string{"Вы"}, 0, 100, 10, []int{0})
// доигрываем раунд ботом (человек тоже отыгрывает автоматически через Stand)
for g.Phase == BJPhasePlayerTurn {
if err := g.Stand(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
if g.Players[0].Balance != 0 {
t.Errorf("при нулевой ставке баланс не должен был измениться ни при каком исходе, получено %d", g.Players[0].Balance)
}
}
func TestBlackjackMultipleRoundsNeverGoNegativeWithLowCapital(t *testing.T) {
names := []string{"Вы", от1", от2"}
g := NewBlackjackGame(names, 0, 15, 10) // капитала хватит всего на 1-2 полные ставки
bot := BlackjackBot{}
for round := 0; round < 20; round++ {
for steps := 0; steps < 100 && g.Phase != BJPhaseRoundOver; steps++ {
if err := bot.PlayFullTurn(g); err != nil {
t.Fatalf("раунд %d: неожиданная ошибка бота: %v", round, err)
}
}
for _, p := range g.Players {
if p.Balance < 0 {
t.Fatalf("раунд %d: баланс игрока %s ушёл в минус: %d", round, p.Name, p.Balance)
}
}
g = g.NextRound()
}
}

View file

@ -114,6 +114,13 @@ func (m BlackjackModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.game = m.game.NextRound()
m.message = ""
m.isError = false
if m.humanIdx >= 0 && m.humanIdx < len(m.game.RoundBetReduced) && m.game.RoundBetReduced[m.humanIdx] {
if m.game.Players[m.humanIdx].Bet == 0 {
m.setInfo(T("blackjack.msg.no_funds"))
} else {
m.setInfo(T("blackjack.msg.bet_reduced"), m.game.Players[m.humanIdx].Bet)
}
}
return m, m.maybeScheduleBot()
}
return m, nil
@ -156,11 +163,7 @@ func renderHandCards(hand []Card) string {
if len(hand) == 0 {
return dimStyle.Render(T("common.no_cards"))
}
parts := make([]string, len(hand))
for i, c := range hand {
parts[i] = renderCard(c)
}
return strings.Join(parts, " ")
return renderCardBoxHand(hand, nil)
}
func statusLabel(s BlackjackStatus) string {
@ -180,17 +183,18 @@ 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 {
isTurn := m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx
if isTurn {
marker = "> "
}
name := p.Name
total := handTotal(p.Hand)
line := fmt.Sprintf(T("blackjack.player_line"),
marker, name, p.Bet, renderHandCards(p.Hand), total, statusLabel(p.Status))
if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx {
line = turnStyle.Render(line)
header := fmt.Sprintf(T("blackjack.player_header"), marker, p.Name, p.Bet, total, statusLabel(p.Status))
if isTurn {
header = turnStyle.Render(header)
}
fmt.Fprintln(&b, line)
fmt.Fprintln(&b, header)
fmt.Fprintln(&b, renderHandCards(p.Hand))
fmt.Fprintln(&b)
}
return strings.TrimRight(b.String(), "\n")
}
@ -198,10 +202,15 @@ func (m BlackjackModel) renderPlayers() string {
func (m BlackjackModel) renderDealer() string {
if m.game.Phase == BJPhasePlayerTurn {
// вторая карта дилера скрыта, пока не завершится раунд
hidden := dimStyle.Render("??")
return fmt.Sprintf("%s %s", renderCard(m.game.Dealer[0]), hidden)
visible := renderCardBox(m.game.Dealer[0], CardNormal)
hidden := renderFaceDownBox()
lines := make([]string, 4)
for i := 0; i < 4; i++ {
lines[i] = visible[i] + " " + hidden[i]
}
return fmt.Sprintf(T("blackjack.dealer_result"), renderHandCards(m.game.Dealer), handTotal(m.game.Dealer))
return strings.Join(lines, "\n")
}
return Tf("blackjack.hand_with_total", renderHandCards(m.game.Dealer), handTotal(m.game.Dealer))
}
func (m BlackjackModel) View() string {
@ -244,7 +253,7 @@ func (m BlackjackModel) View() string {
func (m BlackjackModel) renderResult() string {
res := m.game.Result
var b strings.Builder
dealerLine := fmt.Sprintf(T("blackjack.dealer_result"), renderHandCards(m.game.Dealer), res.DealerTotal)
dealerLine := Tf("blackjack.hand_with_total", renderHandCards(m.game.Dealer), res.DealerTotal)
if res.DealerBusted {
dealerLine += T("blackjack.busted_suffix")
}

View file

@ -153,3 +153,47 @@ func TestBlackjackModelFullAutoPlaythrough(t *testing.T) {
}
}
}
func TestBlackjackModelShowsMessageWhenBetReduced(t *testing.T) {
m := NewBlackjackModel(1, 100, 10)
for m.game.Phase == BJPhasePlayerTurn {
if err := m.game.Stand(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
m.game.Players[m.humanIdx].Balance = 4 // меньше полной ставки 10, но больше нуля
next, _ := m.Update(key("n"))
m = next.(BlackjackModel)
if !m.game.RoundBetReduced[m.humanIdx] {
t.Fatalf("ожидался флаг урезанной ставки")
}
if m.game.Players[m.humanIdx].Bet != 4 {
t.Errorf("ставка должна была урезаться до 4, получено %d", m.game.Players[m.humanIdx].Bet)
}
if m.message == "" {
t.Errorf("ожидалось информационное сообщение об урезанной ставке")
}
if m.isError {
t.Errorf("сообщение об урезанной ставке не должно быть ошибкой")
}
}
func TestBlackjackModelShowsMessageWhenNoFundsLeft(t *testing.T) {
m := NewBlackjackModel(1, 100, 10)
for m.game.Phase == BJPhasePlayerTurn {
if err := m.game.Stand(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
m.game.Players[m.humanIdx].Balance = 0
next, _ := m.Update(key("n"))
m = next.(BlackjackModel)
if m.game.Players[m.humanIdx].Bet != 0 {
t.Errorf("ставка при отсутствии денег должна была стать 0, получено %d", m.game.Players[m.humanIdx].Bet)
}
if m.message == "" {
t.Errorf("ожидалось информационное сообщение об отсутствии денег")
}
}

View file

@ -2,6 +2,7 @@ package main
import (
"fmt"
"math/rand"
"strings"
"time"
@ -39,15 +40,35 @@ type CheckersModel struct {
// NewCheckersModel создаёт новую партию: человек играет белыми.
// depth — глубина поиска бота в полуходах (0 -> используется
// значение по умолчанию из CheckersBot).
func NewCheckersModel(depth int) CheckersModel {
func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel {
human := resolveCheckersColor(colorChoice)
return CheckersModel{
game: NewCheckersGame(),
bot: CheckersBot{Depth: depth},
human: CheckersWhite,
human: human,
cursor: CheckersPos{Row: 5, Col: 0},
}
}
// resolveCheckersColor переводит выбор цвета из настроек в
// конкретный CheckersColor — при случайном выборе бросает монету
// собственным одноразовым генератором (партия в шашки сама по себе
// детерминирована, отдельный rng в модели ради этого не нужен).
func resolveCheckersColor(choice ColorChoice) CheckersColor {
switch choice {
case ColorChoiceA:
return CheckersWhite
case ColorChoiceB:
return CheckersBlack
default:
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
if rnd.Intn(2) == 0 {
return CheckersWhite
}
return CheckersBlack
}
}
func (m CheckersModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}

View file

@ -16,7 +16,7 @@ func TestCheckersModelCursorVisibleOnLegalDestination(t *testing.T) {
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.cursor = CheckersPos{Row: 5, Col: 2}
next, _ := m.Update(key("enter")) // выбираем шашку
m = next.(CheckersModel)
@ -70,7 +70,7 @@ func TestCheckersCursorOnPieceUsesOneCombinedStyle(t *testing.T) {
}
func TestCheckersModelSelectAndMoveViaKeys(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.cursor = CheckersPos{Row: 5, Col: 2}
next, _ := m.Update(key("enter"))
@ -97,7 +97,7 @@ func TestCheckersModelSelectAndMoveViaKeys(t *testing.T) {
}
func TestCheckersModelCursorMovement(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.cursor = CheckersPos{Row: 4, Col: 4}
next, _ := m.Update(key("up"))
@ -113,7 +113,7 @@ func TestCheckersModelCursorMovement(t *testing.T) {
}
func TestCheckersModelEscClearsSelection(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.cursor = CheckersPos{Row: 5, Col: 2}
next, _ := m.Update(key("enter"))
m = next.(CheckersModel)
@ -129,7 +129,7 @@ func TestCheckersModelEscClearsSelection(t *testing.T) {
}
func TestCheckersModelCannotSelectEmptySquare(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.cursor = CheckersPos{Row: 3, Col: 2}
next, _ := m.Update(key("enter"))
@ -140,7 +140,7 @@ func TestCheckersModelCannotSelectEmptySquare(t *testing.T) {
}
func TestCheckersModelQReturnsToMenu(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(CheckersModel)
if !m.backToMenu {
@ -152,7 +152,7 @@ func TestCheckersModelQReturnsToMenu(t *testing.T) {
}
func TestCheckersModelNewGameAfterResult(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.game.Result = &CheckersResult{Winner: CheckersWhite}
next, _ := m.Update(key("n"))
@ -167,7 +167,7 @@ func TestCheckersModelNewGameAfterResult(t *testing.T) {
// (небольшая глубина — ради скорости), проверяя, что весь путь
// через Update не паникует и партия завершается.
func TestCheckersModelFullAutoPlaythrough(t *testing.T) {
m := NewCheckersModel(0)
m := NewCheckersModel(0, ColorChoiceA)
m.bot = CheckersBot{Depth: 2}
helperBot := CheckersBot{Depth: 2}
@ -199,3 +199,32 @@ func TestCheckersModelFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps)
}
}
func TestNewCheckersModelRespectsColorChoiceWhite(t *testing.T) {
m := NewCheckersModel(0, ColorChoiceA)
if m.human != CheckersWhite {
t.Errorf("при выборе ColorChoiceA человек должен был играть белыми, получено %v", m.human)
}
}
func TestNewCheckersModelRespectsColorChoiceBlack(t *testing.T) {
m := NewCheckersModel(0, ColorChoiceB)
if m.human != CheckersBlack {
t.Errorf("при выборе ColorChoiceB человек должен был играть чёрными, получено %v", m.human)
}
}
func TestNewCheckersModelRandomColorGivesValidColor(t *testing.T) {
seenWhite, seenBlack := false, false
for i := 0; i < 50 && !(seenWhite && seenBlack); i++ {
m := NewCheckersModel(0, ColorChoiceRandom)
if m.human == CheckersWhite {
seenWhite = true
} else {
seenBlack = true
}
}
if !seenWhite || !seenBlack {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
}
}

View file

@ -2,6 +2,7 @@ package main
import (
"fmt"
"math/rand"
"strings"
"time"
@ -29,15 +30,35 @@ type ChessModel struct {
// NewChessModel создаёт новую партию: человек играет белыми. depth —
// глубина поиска бота в полуходах (0 -> значение по умолчанию).
func NewChessModel(depth int) ChessModel {
func NewChessModel(depth int, colorChoice ColorChoice) ChessModel {
human := resolveChessColor(colorChoice)
return ChessModel{
game: NewChessGame(),
bot: ChessBot{Depth: depth},
human: ChessWhite,
human: human,
cursor: ChessPos{Row: 7, Col: 4},
}
}
// resolveChessColor переводит выбор цвета из настроек в конкретный
// ChessColor — при случайном выборе бросает монету одноразовым
// генератором (партия сама по себе детерминирована, отдельный rng в
// модели ради этого не нужен).
func resolveChessColor(choice ColorChoice) ChessColor {
switch choice {
case ColorChoiceA:
return ChessWhite
case ColorChoiceB:
return ChessBlack
default:
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
if rnd.Intn(2) == 0 {
return ChessWhite
}
return ChessBlack
}
}
func (m ChessModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}

View file

@ -9,7 +9,7 @@ import (
)
func TestChessModelSelectAndMoveViaKeys(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter"))
@ -36,7 +36,7 @@ func TestChessModelSelectAndMoveViaKeys(t *testing.T) {
}
func TestChessModelCastlingViaKeys(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.game = &ChessGameState{Turn: ChessWhite}
m.game.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing}
m.game.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook}
@ -99,7 +99,7 @@ func TestChessModelCursorVisibleOnLegalDestination(t *testing.T) {
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter")) // выбираем пешку e2
m = next.(ChessModel)
@ -122,7 +122,7 @@ func TestChessModelCursorVisibleOnLegalDestination(t *testing.T) {
}
func TestChessModelCursorMovement(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 4, Col: 4}
next, _ := m.Update(key("up"))
@ -138,7 +138,7 @@ func TestChessModelCursorMovement(t *testing.T) {
}
func TestChessModelEscClearsSelection(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 6, Col: 4}
next, _ := m.Update(key("enter"))
m = next.(ChessModel)
@ -154,7 +154,7 @@ func TestChessModelEscClearsSelection(t *testing.T) {
}
func TestChessModelCannotSelectEmptySquare(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.cursor = ChessPos{Row: 4, Col: 4}
next, _ := m.Update(key("enter"))
@ -165,7 +165,7 @@ func TestChessModelCannotSelectEmptySquare(t *testing.T) {
}
func TestChessModelQReturnsToMenu(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(ChessModel)
if !m.backToMenu {
@ -177,7 +177,7 @@ func TestChessModelQReturnsToMenu(t *testing.T) {
}
func TestChessModelNewGameAfterResult(t *testing.T) {
m := NewChessModel(0)
m := NewChessModel(0, ColorChoiceA)
m.game.Result = &ChessResult{Winner: ChessWhite, Checkmate: true}
next, _ := m.Update(key("n"))
@ -192,7 +192,7 @@ func TestChessModelNewGameAfterResult(t *testing.T) {
// (небольшая глубина — ради скорости), проверяя, что весь путь
// через Update не паникует и партия завершается.
func TestChessModelFullAutoPlaythrough(t *testing.T) {
m := NewChessModel(1)
m := NewChessModel(1, ColorChoiceA)
helperBot := ChessBot{Depth: 1}
const maxSteps = 600
@ -223,3 +223,32 @@ func TestChessModelFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps)
}
}
func TestNewChessModelRespectsColorChoiceWhite(t *testing.T) {
m := NewChessModel(0, ColorChoiceA)
if m.human != ChessWhite {
t.Errorf("при выборе ColorChoiceA человек должен был играть белыми, получено %v", m.human)
}
}
func TestNewChessModelRespectsColorChoiceBlack(t *testing.T) {
m := NewChessModel(0, ColorChoiceB)
if m.human != ChessBlack {
t.Errorf("при выборе ColorChoiceB человек должен был играть чёрными, получено %v", m.human)
}
}
func TestNewChessModelRandomColorGivesValidColor(t *testing.T) {
seenWhite, seenBlack := false, false
for i := 0; i < 50 && !(seenWhite && seenBlack); i++ {
m := NewChessModel(0, ColorChoiceRandom)
if m.human == ChessWhite {
seenWhite = true
} else {
seenBlack = true
}
}
if !seenWhite || !seenBlack {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
}
}

View file

@ -298,15 +298,7 @@ func (m DurakModel) renderHand() string {
if len(hand) == 0 {
return dimStyle.Render(T("common.no_cards"))
}
parts := make([]string, len(hand))
for i, c := range hand {
text := renderCard(c)
if i == m.cursor {
text = cursorStyle.Render(c.String())
}
parts[i] = text
}
return strings.Join(parts, " ")
return renderCardBoxHand(hand, cursorHighlights(len(hand), m.cursor))
}
func (m DurakModel) renderTable() string {

BIN
ggc (Stored with Git LFS)

Binary file not shown.

BIN
ggc.exe (Stored with Git LFS)

Binary file not shown.

View file

@ -33,18 +33,38 @@ type GoModel struct {
// NewGoModel создаёт новую партию на доске size x size с ботом
// уровня difficulty. Человек играет чёрными.
func NewGoModel(size int, difficulty GoDifficulty) GoModel {
func NewGoModel(size int, difficulty GoDifficulty, colorChoice ColorChoice) GoModel {
human := resolveGoColor(colorChoice)
return GoModel{
game: NewGoGame(size),
bot: GoBot{Difficulty: difficulty},
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
human: GoBlack,
human: human,
cursor: GoPos{Row: size / 2, Col: size / 2},
size: size,
difficulty: difficulty,
}
}
// resolveGoColor переводит выбор цвета из настроек в конкретный
// GoColor. ColorChoiceA — чёрные (традиционно первый ход в Го,
// соответствует прежнему поведению по умолчанию), ColorChoiceB —
// белые.
func resolveGoColor(choice ColorChoice) GoColor {
switch choice {
case ColorChoiceA:
return GoBlack
case ColorChoiceB:
return GoWhite
default:
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
if rnd.Intn(2) == 0 {
return GoBlack
}
return GoWhite
}
}
func (m GoModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}

View file

@ -3,7 +3,7 @@ package main
import "testing"
func TestGoModelPlayStoneViaKey(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
m.cursor = GoPos{Row: 4, Col: 4}
next, _ := m.Update(key("enter"))
@ -17,7 +17,7 @@ func TestGoModelPlayStoneViaKey(t *testing.T) {
}
func TestGoModelCursorMovement(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
m.cursor = GoPos{Row: 4, Col: 4}
next, _ := m.Update(key("up"))
@ -33,7 +33,7 @@ func TestGoModelCursorMovement(t *testing.T) {
}
func TestGoModelCursorClampsAtEdges(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
m.cursor = GoPos{Row: 0, Col: 0}
next, _ := m.Update(key("up"))
@ -49,7 +49,7 @@ func TestGoModelCursorClampsAtEdges(t *testing.T) {
}
func TestGoModelPassViaKey(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
next, _ := m.Update(key("p"))
m = next.(GoModel)
if m.isError {
@ -58,7 +58,7 @@ func TestGoModelPassViaKey(t *testing.T) {
}
func TestGoModelInvalidMoveShowsError(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
m.cursor = GoPos{Row: 0, Col: 0}
m.game.Board[0][0] = GoBlack
@ -75,7 +75,7 @@ func TestGoModelInvalidMoveShowsError(t *testing.T) {
// сообщение "Бот сходил.", что и при обычном ходе, из-за чего
// казалось, будто ход просто не отрисовался.
func TestGoModelBotPassShowsDistinctMessage(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
// заполняем всю доску без единой пустой клетки — тогда легальных
// ходов гарантированно нет ни у кого, пас неизбежен
for r := 0; r < 9; r++ {
@ -100,7 +100,7 @@ func TestGoModelBotPassShowsDistinctMessage(t *testing.T) {
}
func TestGoModelQReturnsToMenu(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(GoModel)
if !m.backToMenu {
@ -112,7 +112,7 @@ func TestGoModelQReturnsToMenu(t *testing.T) {
}
func TestGoModelNewGameAfterResult(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
m.game.Result = &GoResult{Winner: GoBlack, BlackScore: 10, WhiteScore: 7.5}
next, _ := m.Update(key("n"))
@ -127,7 +127,7 @@ func TestGoModelNewGameAfterResult(t *testing.T) {
func TestGoModelSupportsAllBoardSizes(t *testing.T) {
for _, size := range []int{9, 13, 19} {
m := NewGoModel(size, GoDifficultyMedium)
m := NewGoModel(size, GoDifficultyMedium, ColorChoiceA)
if m.game.Size != size {
t.Errorf("ожидался размер доски %d, получено %d", size, m.game.Size)
}
@ -139,7 +139,7 @@ func TestGoModelSupportsAllBoardSizes(t *testing.T) {
// через бота, проверяя, что весь путь через Update не паникует и
// партия завершается.
func TestGoModelFullAutoPlaythrough(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy)
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
helperBot := GoBot{Difficulty: GoDifficultyEasy}
rnd := m.rnd
@ -167,3 +167,32 @@ func TestGoModelFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps)
}
}
func TestNewGoModelRespectsColorChoiceBlack(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceA)
if m.human != GoBlack {
t.Errorf("при выборе ColorChoiceA человек должен был играть чёрными, получено %v", m.human)
}
}
func TestNewGoModelRespectsColorChoiceWhite(t *testing.T) {
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceB)
if m.human != GoWhite {
t.Errorf("при выборе ColorChoiceB человек должен был играть белыми, получено %v", m.human)
}
}
func TestNewGoModelRandomColorGivesValidColor(t *testing.T) {
seenBlack, seenWhite := false, false
for i := 0; i < 50 && !(seenBlack && seenWhite); i++ {
m := NewGoModel(9, GoDifficultyEasy, ColorChoiceRandom)
if m.human == GoBlack {
seenBlack = true
} else {
seenWhite = true
}
}
if !seenBlack || !seenWhite {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: чёрные=%v белые=%v", seenBlack, seenWhite)
}
}

View file

@ -214,31 +214,43 @@ func (m KlondikeModel) pileHasMovableCard(ref klondikePileRef) bool {
// --- Отрисовка -----------------------------------------------------
func (m KlondikeModel) renderFoundations() string {
var parts []string
suits := []Suit{Clubs, Diamonds, Hearts, Spades}
for _, s := range suits {
boxes := make([][]string, 4)
for i, s := range suits {
rank := m.game.Foundations[s]
if rank == 0 {
parts = append(parts, dimStyle.Render("["+s.String()+" --]"))
top := "┌───┐"
bottom := "└───┘"
label := dimStyle.Render(" " + s.String() + " ")
boxes[i] = []string{dimStyle.Render(top), "│" + label + "│", "│ │", dimStyle.Render(bottom)}
continue
}
card := Card{Suit: s, Rank: Rank(rank)}
parts = append(parts, "["+renderCard(card)+"]")
boxes[i] = renderCardBox(card, CardNormal)
}
return strings.Join(parts, " ")
lines := make([]string, 4)
for row := 0; row < 4; row++ {
parts := make([]string, 4)
for i := range suits {
parts[i] = boxes[i][row]
}
lines[row] = strings.Join(parts, " ")
}
return strings.Join(lines, "\n")
}
func (m KlondikeModel) renderStockWaste() string {
stockStr := dimStyle.Render(fmt.Sprintf("(%d)", len(m.game.Stock)))
wasteStr := dimStyle.Render(T("klondike.empty"))
if len(m.game.Waste) > 0 {
stockLine := Tf("klondike.stock_line", len(m.game.Stock))
if len(m.game.Waste) == 0 {
return stockLine + " " + dimStyle.Render(T("klondike.empty"))
}
top := m.game.Waste[len(m.game.Waste)-1]
wasteStr = renderCard(top)
highlight := CardNormal
if m.selected == klondikeWasteRef {
wasteStr = cursorStyle.Render(top.String())
highlight = CardCursor
}
}
return fmt.Sprintf(T("klondike.status_line"), stockStr, wasteStr)
waste := strings.Join(renderCardBox(top, highlight), "\n")
return stockLine + " " + T("klondike.waste_label") + "\n" + waste
}
func (m KlondikeModel) renderTableau() string {

216
menu.go
View file

@ -38,9 +38,10 @@ type GameEntry struct {
NewBlackjackWithSettings func(numPlayers, startingCapital, bet int) tea.Model
// UsesCheckersSettings — включает экран выбора уровня сложности
// бота (простой/сильный) перед началом партии.
// бота (простой/сильный) и цвета, за который играет человек,
// перед началом партии.
UsesCheckersSettings bool
NewCheckersWithDifficulty func(depth int) tea.Model
NewCheckersWithDifficulty func(depth int, colorChoice ColorChoice) tea.Model
// UsesTicTacToeSettings — включает экран выбора уровня сложности
// бота (лёгкий/средний/непобедимый) перед партией.
@ -50,27 +51,28 @@ type GameEntry struct {
// UsesNardySettings — включает экран выбора варианта правил
// (короткие/длинные) и уровня сложности бота перед партией.
UsesNardySettings bool
NewNardyWithSettings func(variant NardyVariant, difficulty NardyDifficulty) tea.Model
NewNardyWithSettings func(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) tea.Model
// UsesUrSettings — включает экран выбора уровня сложности бота
// перед партией в Ур.
UsesUrSettings bool
NewUrWithDifficulty func(difficulty UrDifficulty) tea.Model
NewUrWithDifficulty func(difficulty UrDifficulty, colorChoice ColorChoice) tea.Model
// UsesSenetSettings — включает экран выбора уровня сложности
// бота перед партией в Сенет.
UsesSenetSettings bool
NewSenetWithDifficulty func(difficulty SenetDifficulty) tea.Model
NewSenetWithDifficulty func(difficulty SenetDifficulty, colorChoice ColorChoice) tea.Model
// UsesChessSettings — то же самое, но для шахмат (свой уровень
// глубины поиска, т.к. ветвление в шахматах намного шире).
// глубины поиска, т.к. ветвление в шахматах намного шире), плюс
// выбор цвета.
UsesChessSettings bool
NewChessWithDifficulty func(depth int) tea.Model
NewChessWithDifficulty func(depth int, colorChoice ColorChoice) tea.Model
// UsesGoSettings — экран с двумя полями: размер доски (9/13/19)
// и уровень сложности бота.
UsesGoSettings bool
NewGoWithSettings func(size int, difficulty GoDifficulty) tea.Model
NewGoWithSettings func(size int, difficulty GoDifficulty, colorChoice ColorChoice) tea.Model
// UsesMinesweeperSettings — экран выбора пресета размера поля
// (Новичок/Любитель/Эксперт).
@ -130,8 +132,8 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.checkers.desc") },
Rules: checkersRules,
UsesCheckersSettings: true,
NewCheckersWithDifficulty: func(depth int) tea.Model {
return NewCheckersModel(depth)
NewCheckersWithDifficulty: func(depth int, colorChoice ColorChoice) tea.Model {
return NewCheckersModel(depth, colorChoice)
},
},
{
@ -139,8 +141,8 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.chess.desc") },
Rules: chessRules,
UsesChessSettings: true,
NewChessWithDifficulty: func(depth int) tea.Model {
return NewChessModel(depth)
NewChessWithDifficulty: func(depth int, colorChoice ColorChoice) tea.Model {
return NewChessModel(depth, colorChoice)
},
},
{
@ -154,8 +156,8 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.go.desc") },
Rules: goRules,
UsesGoSettings: true,
NewGoWithSettings: func(size int, difficulty GoDifficulty) tea.Model {
return NewGoModel(size, difficulty)
NewGoWithSettings: func(size int, difficulty GoDifficulty, colorChoice ColorChoice) tea.Model {
return NewGoModel(size, difficulty, colorChoice)
},
},
{
@ -187,8 +189,8 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.nardy.desc") },
Rules: nardyRules,
UsesNardySettings: true,
NewNardyWithSettings: func(variant NardyVariant, difficulty NardyDifficulty) tea.Model {
return NewNardyModel(variant, difficulty)
NewNardyWithSettings: func(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) tea.Model {
return NewNardyModel(variant, difficulty, colorChoice)
},
},
{
@ -196,8 +198,8 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.ur.desc") },
Rules: urRules,
UsesUrSettings: true,
NewUrWithDifficulty: func(difficulty UrDifficulty) tea.Model {
return NewUrModel(difficulty)
NewUrWithDifficulty: func(difficulty UrDifficulty, colorChoice ColorChoice) tea.Model {
return NewUrModel(difficulty, colorChoice)
},
},
{
@ -205,10 +207,18 @@ var gameRegistry = []GameEntry{
Description: func() string { return T("game.senet.desc") },
Rules: senetRules,
UsesSenetSettings: true,
NewSenetWithDifficulty: func(difficulty SenetDifficulty) tea.Model {
return NewSenetModel(difficulty)
NewSenetWithDifficulty: func(difficulty SenetDifficulty, colorChoice ColorChoice) tea.Model {
return NewSenetModel(difficulty, colorChoice)
},
},
{
Name: func() string { return T("game.poker.name") },
Description: func() string { return T("game.poker.desc") },
Rules: pokerRules,
New: func() tea.Model { return NewPokerModel(4) },
UsesPlayerCount: true,
NewWithPlayerCount: func(n int) tea.Model { return NewPokerModel(n) },
},
}
type appState int
@ -315,6 +325,8 @@ func (s *playerCountSettings) cycle(delta int) {
// партией в шашки: два уровня, разница только в глубине поиска.
type checkersSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
gameIdx int
}
@ -332,6 +344,8 @@ func newCheckersSettings(gameIdx int) checkersSettings {
}
func (s *checkersSettings) cycle(delta int) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
@ -339,6 +353,9 @@ func (s *checkersSettings) cycle(delta int) {
if s.levelIdx > len(checkersLevels)-1 {
s.levelIdx = len(checkersLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// ticTacToeSettings — экран выбора уровня сложности бота перед
@ -377,6 +394,8 @@ func (s *ticTacToeSettings) cycle(delta int) {
// намного шире, чем в шашках).
type chessSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
gameIdx int
}
@ -394,6 +413,8 @@ func newChessSettings(gameIdx int) chessSettings {
}
func (s *chessSettings) cycle(delta int) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
@ -401,6 +422,9 @@ func (s *chessSettings) cycle(delta int) {
if s.levelIdx > len(chessLevels)-1 {
s.levelIdx = len(chessLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// goSettings — экран настройки партии в Го: два поля (размер доски
@ -409,7 +433,8 @@ type goSettings struct {
sizeOptions []int
sizeIdx int
difficultyIdx int
fieldCursor int // 0 = размер доски, 1 = сложность
colorChoice ColorChoice
fieldCursor int // 0 = размер доски, 1 = сложность, 2 = цвет
gameIdx int
}
@ -450,6 +475,8 @@ func (s *goSettings) cycle(delta int) {
s.sizeIdx = clamp(s.sizeIdx, len(s.sizeOptions)-1)
case 1:
s.difficultyIdx = clamp(s.difficultyIdx, len(goDifficultyLevels)-1)
case 2:
s.colorChoice.cycle(delta)
}
}
@ -458,7 +485,8 @@ func (s *goSettings) cycle(delta int) {
type nardySettings struct {
variantIdx int
difficultyIdx int
fieldCursor int // 0 = вариант правил, 1 = сложность
colorChoice ColorChoice
fieldCursor int // 0 = вариант правил, 1 = сложность, 2 = цвет
gameIdx int
}
@ -505,12 +533,16 @@ func (s *nardySettings) cycle(delta int) {
s.variantIdx = clamp(s.variantIdx, len(nardyVariantOptions)-1)
case 1:
s.difficultyIdx = clamp(s.difficultyIdx, len(nardyDifficultyLevels)-1)
case 2:
s.colorChoice.cycle(delta)
}
}
// urSettings — экран выбора уровня сложности бота перед партией в Ур.
type urSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
gameIdx int
}
@ -529,6 +561,8 @@ func newUrSettings(gameIdx int) urSettings {
}
func (s *urSettings) cycle(delta int) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
@ -536,12 +570,17 @@ func (s *urSettings) cycle(delta int) {
if s.levelIdx > len(urDifficultyLevels)-1 {
s.levelIdx = len(urDifficultyLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// senetSettings — экран выбора уровня сложности бота перед партией
// в Сенет.
type senetSettings struct {
levelIdx int
colorChoice ColorChoice
fieldCursor int // 0 = сложность, 1 = цвет
gameIdx int
}
@ -560,6 +599,8 @@ func newSenetSettings(gameIdx int) senetSettings {
}
func (s *senetSettings) cycle(delta int) {
switch s.fieldCursor {
case 0:
s.levelIdx += delta
if s.levelIdx < 0 {
s.levelIdx = 0
@ -567,6 +608,9 @@ func (s *senetSettings) cycle(delta int) {
if s.levelIdx > len(senetDifficultyLevels)-1 {
s.levelIdx = len(senetDifficultyLevels) - 1
}
case 1:
s.colorChoice.cycle(delta)
}
}
// minesweeperSettings — экран выбора пресета размера поля Сапёра.
@ -1156,14 +1200,22 @@ func (m AppModel) updateCheckersSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k", "left", "h":
case "up", "k":
if m.checkers.fieldCursor > 0 {
m.checkers.fieldCursor--
}
case "down", "j":
if m.checkers.fieldCursor < 1 {
m.checkers.fieldCursor++
}
case "left", "h":
m.checkers.cycle(-1)
case "down", "j", "right", "l":
case "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.activeGame = entry.NewCheckersWithDifficulty(depth, m.checkers.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1213,7 +1265,7 @@ func (m AppModel) updateNardySettings(msg tea.Msg) (tea.Model, tea.Cmd) {
m.nardy.fieldCursor--
}
case "down", "j":
if m.nardy.fieldCursor < 1 {
if m.nardy.fieldCursor < 2 {
m.nardy.fieldCursor++
}
case "left", "h":
@ -1222,7 +1274,7 @@ func (m AppModel) updateNardySettings(msg tea.Msg) (tea.Model, tea.Cmd) {
m.nardy.cycle(1)
case "enter":
entry := gameRegistry[m.nardy.gameIdx]
m.activeGame = entry.NewNardyWithSettings(m.nardy.variant(), m.nardy.difficulty())
m.activeGame = entry.NewNardyWithSettings(m.nardy.variant(), m.nardy.difficulty(), m.nardy.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1241,14 +1293,22 @@ func (m AppModel) updateUrSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k", "left", "h":
case "up", "k":
if m.ur.fieldCursor > 0 {
m.ur.fieldCursor--
}
case "down", "j":
if m.ur.fieldCursor < 1 {
m.ur.fieldCursor++
}
case "left", "h":
m.ur.cycle(-1)
case "down", "j", "right", "l":
case "right", "l":
m.ur.cycle(1)
case "enter":
entry := gameRegistry[m.ur.gameIdx]
difficulty := urDifficultyLevels[m.ur.levelIdx].Difficulty
m.activeGame = entry.NewUrWithDifficulty(difficulty)
m.activeGame = entry.NewUrWithDifficulty(difficulty, m.ur.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1267,14 +1327,22 @@ func (m AppModel) updateSenetSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k", "left", "h":
case "up", "k":
if m.senet.fieldCursor > 0 {
m.senet.fieldCursor--
}
case "down", "j":
if m.senet.fieldCursor < 1 {
m.senet.fieldCursor++
}
case "left", "h":
m.senet.cycle(-1)
case "down", "j", "right", "l":
case "right", "l":
m.senet.cycle(1)
case "enter":
entry := gameRegistry[m.senet.gameIdx]
difficulty := senetDifficultyLevels[m.senet.levelIdx].Difficulty
m.activeGame = entry.NewSenetWithDifficulty(difficulty)
m.activeGame = entry.NewSenetWithDifficulty(difficulty, m.senet.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1293,14 +1361,22 @@ func (m AppModel) updateChessSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc", "q":
m.state = stateMenu
return m, nil
case "up", "k", "left", "h":
case "up", "k":
if m.chess.fieldCursor > 0 {
m.chess.fieldCursor--
}
case "down", "j":
if m.chess.fieldCursor < 1 {
m.chess.fieldCursor++
}
case "left", "h":
m.chess.cycle(-1)
case "down", "j", "right", "l":
case "right", "l":
m.chess.cycle(1)
case "enter":
entry := gameRegistry[m.chess.gameIdx]
depth := chessLevels[m.chess.levelIdx].Depth
m.activeGame = entry.NewChessWithDifficulty(depth)
m.activeGame = entry.NewChessWithDifficulty(depth, m.chess.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1324,7 +1400,7 @@ func (m AppModel) updateGoSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
m.goSetup.fieldCursor--
}
case "down", "j":
if m.goSetup.fieldCursor < 1 {
if m.goSetup.fieldCursor < 2 {
m.goSetup.fieldCursor++
}
case "left", "h":
@ -1333,7 +1409,7 @@ func (m AppModel) updateGoSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
m.goSetup.cycle(1)
case "enter":
entry := gameRegistry[m.goSetup.gameIdx]
m.activeGame = entry.NewGoWithSettings(m.goSetup.size(), m.goSetup.difficulty())
m.activeGame = entry.NewGoWithSettings(m.goSetup.size(), m.goSetup.difficulty(), m.goSetup.colorChoice)
m.state = statePlaying
return m, m.activeGame.Init()
}
@ -1444,6 +1520,8 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
backToMenu = tm.backToMenu
case UrModel:
backToMenu = tm.backToMenu
case PokerModel:
backToMenu = tm.backToMenu
}
if backToMenu {
m.state = stateMenu
@ -1706,10 +1784,15 @@ func (m AppModel) viewCheckersSettings() string {
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name())))
fmt.Fprintln(&b)
for i, lvl := range checkersLevels {
lvl := checkersLevels[m.checkers.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.checkers.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey))
if i == m.checkers.levelIdx {
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.checkers.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
@ -1717,7 +1800,9 @@ func (m AppModel) viewCheckersSettings() string {
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint")))
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
return b.String()
}
@ -1754,6 +1839,7 @@ func (m AppModel) viewNardySettings() string {
}{
{T("nardysettings.variant"), T(nardyVariantOptions[m.nardy.variantIdx].LabelKey)},
{T("nardysettings.difficulty"), T(nardyDifficultyLevels[m.nardy.difficultyIdx].LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.nardy.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
@ -1779,10 +1865,15 @@ func (m AppModel) viewUrSettings() string {
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name())))
fmt.Fprintln(&b)
for i, lvl := range urDifficultyLevels {
lvl := urDifficultyLevels[m.ur.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.ur.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey))
if i == m.ur.levelIdx {
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.ur.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
@ -1790,7 +1881,9 @@ func (m AppModel) viewUrSettings() string {
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint")))
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
return b.String()
}
@ -1800,10 +1893,15 @@ func (m AppModel) viewSenetSettings() string {
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name())))
fmt.Fprintln(&b)
for i, lvl := range senetDifficultyLevels {
lvl := senetDifficultyLevels[m.senet.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.senet.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey))
if i == m.senet.levelIdx {
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.senet.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
@ -1811,7 +1909,9 @@ func (m AppModel) viewSenetSettings() string {
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint")))
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
return b.String()
}
@ -1821,10 +1921,15 @@ func (m AppModel) viewChessSettings() string {
fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name())))
fmt.Fprintln(&b)
for i, lvl := range chessLevels {
lvl := chessLevels[m.chess.levelIdx]
fields := []struct{ label, value string }{
{T("checkerslvl.field_label"), T(lvl.LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.chess.colorChoice, "color.white", "color.black")},
}
for i, f := range fields {
cursor := " "
line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey))
if i == m.chess.levelIdx {
line := fmt.Sprintf("%-30s ← %s →", f.label, f.value)
if i == m.chess.fieldCursor {
cursor = menuCursorStyle.Render("> ")
line = menuCursorStyle.Render(line)
}
@ -1832,7 +1937,9 @@ func (m AppModel) viewChessSettings() string {
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint")))
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b)
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
return b.String()
}
@ -1848,6 +1955,7 @@ func (m AppModel) viewGoSettings() string {
}{
{T("gosettings.boardsize"), fmt.Sprintf("%dx%d", m.goSetup.size(), m.goSetup.size())},
{T("gosettings.difficulty"), T(goDifficultyLevels[m.goSetup.difficultyIdx].LabelKey)},
{T("color.field_label"), colorChoiceLabel(m.goSetup.colorChoice, "color.black", "color.white")},
}
for i, f := range fields {
cursor := " "

View file

@ -29,16 +29,17 @@ func TestQReturnsToMenuForAllGames(t *testing.T) {
{"101", func() tea.Model { return NewOneOhOneModel(2) }},
{"1000", func() tea.Model { return NewThousandModel() }},
{"klondike", func() tea.Model { return NewKlondikeModel() }},
{"checkers", func() tea.Model { return NewCheckersModel(CheckersDifficultyEasy) }},
{"chess", func() tea.Model { return NewChessModel(CheckersDifficultyEasy) }},
{"checkers", func() tea.Model { return NewCheckersModel(CheckersDifficultyEasy, ColorChoiceA) }},
{"chess", func() tea.Model { return NewChessModel(CheckersDifficultyEasy, ColorChoiceA) }},
{"corpsestarch", func() tea.Model { return NewCorpseStarchModel() }},
{"go", func() tea.Model { return NewGoModel(9, GoDifficultyEasy) }},
{"go", func() tea.Model { return NewGoModel(9, GoDifficultyEasy, ColorChoiceA) }},
{"minesweeper", func() tea.Model { return NewMinesweeperModel(9, 9, 10) }},
{"wayfarer", func() tea.Model { return NewWayfarerModel() }},
{"tictactoe", func() tea.Model { return NewTicTacToeModel(TicTacToeDifficultyEasy) }},
{"nardy", func() tea.Model { return NewNardyModel(NardyShort, NardyDifficultyEasy) }},
{"senet", func() tea.Model { return NewSenetModel(SenetDifficultyEasy) }},
{"ur", func() tea.Model { return NewUrModel(UrDifficultyEasy) }},
{"nardy", func() tea.Model { return NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA) }},
{"senet", func() tea.Model { return NewSenetModel(SenetDifficultyEasy, ColorChoiceA) }},
{"ur", func() tea.Model { return NewUrModel(UrDifficultyEasy, ColorChoiceA) }},
{"poker", func() tea.Model { return NewPokerModel(3) }},
}
for _, g := range games {
@ -895,7 +896,7 @@ func TestCheckersDifficultySelectionAndStart(t *testing.T) {
t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.checkers.levelIdx)
}
next, _ = m.Update(key("down")) // переключаемся на "Сильный"
next, _ = m.Update(key("right")) // переключаемся на "Сильный"
m = next.(AppModel)
if m.checkers.levelIdx != 1 {
t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.checkers.levelIdx)
@ -1371,7 +1372,7 @@ func TestChessDifficultySelectionAndStart(t *testing.T) {
t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.chess.levelIdx)
}
next, _ = m.Update(key("down")) // переключаемся на "Сильный"
next, _ = m.Update(key("right")) // переключаемся на "Сильный"
m = next.(AppModel)
if m.chess.levelIdx != 1 {
t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.chess.levelIdx)

View file

@ -42,9 +42,9 @@ type NardyModel struct {
// больше — тот и первый); при равенстве — переброс, как и
// предписывают правила. Человек всегда играет белыми в этой версии
// (цвет — условность отображения, на баланс сил не влияет).
func NewNardyModel(variant NardyVariant, difficulty NardyDifficulty) NardyModel {
func NewNardyModel(variant NardyVariant, difficulty NardyDifficulty, colorChoice ColorChoice) NardyModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
humanColor := NardyWhite
humanColor := resolveNardyColor(colorChoice, rng)
firstPlayer := NardyWhite
for {
human, bot := rng.Intn(6)+1, rng.Intn(6)+1
@ -65,6 +65,24 @@ func NewNardyModel(variant NardyVariant, difficulty NardyDifficulty) NardyModel
}
}
// resolveNardyColor переводит выбор цвета из настроек в конкретный
// NardyPlayer, используя уже созданный генератор модели для
// случайного выбора (в отличие от Шашек/Шахмат/Го, у Нард и так уже
// есть постоянный rng для бросков костей).
func resolveNardyColor(choice ColorChoice, rng *rand.Rand) NardyPlayer {
switch choice {
case ColorChoiceA:
return NardyWhite
case ColorChoiceB:
return NardyBlack
default:
if rng.Intn(2) == 0 {
return NardyWhite
}
return NardyBlack
}
}
func (m NardyModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}

View file

@ -6,7 +6,7 @@ import (
)
func TestNardyModelRollViaKey(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
@ -21,7 +21,7 @@ func TestNardyModelRollViaKey(t *testing.T) {
}
func TestNardyModelApplyMoveViaDigitKey(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
m.game.Roll(3, 4)
@ -47,7 +47,7 @@ func TestNardyModelApplyMoveViaDigitKey(t *testing.T) {
}
func TestNardyModelTwoStepDieSelection(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
m.game.Board = [24]NardyPoint{}
@ -83,7 +83,7 @@ func TestNardyModelTwoStepDieSelection(t *testing.T) {
}
func TestNardyModelRejectsMoveKeyDuringRollPhase(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
@ -95,7 +95,7 @@ func TestNardyModelRejectsMoveKeyDuringRollPhase(t *testing.T) {
}
func TestNardyModelRejectsRollKeyDuringMovePhase(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
m.game.Roll(3, 4)
@ -109,7 +109,7 @@ func TestNardyModelRejectsRollKeyDuringMovePhase(t *testing.T) {
}
func TestNardyModelBotPlaysWhenNotHumanTurn(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyBlack)
m.humanColor = NardyWhite
@ -121,7 +121,7 @@ func TestNardyModelBotPlaysWhenNotHumanTurn(t *testing.T) {
}
func TestNardyModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
@ -133,7 +133,7 @@ func TestNardyModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
}
func TestNardyModelQReturnsToMenuNotQuitsApp(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(NardyModel)
if !m.backToMenu {
@ -145,7 +145,7 @@ func TestNardyModelQReturnsToMenuNotQuitsApp(t *testing.T) {
}
func TestNardyModelActionsIgnoredAfterGameOver(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy)
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
m.game = NewNardyGame(NardyShort, NardyWhite)
m.humanColor = NardyWhite
m.game.Phase = NardyPhaseOver
@ -185,7 +185,7 @@ func TestNardyMoveLabelFromBar(t *testing.T) {
func TestNardyModelFullAutoPlaythrough(t *testing.T) {
for _, variant := range []NardyVariant{NardyShort, NardyLong} {
m := NewNardyModel(variant, NardyDifficultyMedium)
m := NewNardyModel(variant, NardyDifficultyMedium, ColorChoiceA)
humanBot := NardyBot{Difficulty: NardyDifficultyMedium}
steps := 0
@ -258,3 +258,32 @@ func TestNardyModelFullAutoPlaythrough(t *testing.T) {
}
}
}
func TestNewNardyModelRespectsColorChoiceWhite(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
if m.humanColor != NardyWhite {
t.Errorf("при выборе ColorChoiceA человек должен был играть белыми, получено %v", m.humanColor)
}
}
func TestNewNardyModelRespectsColorChoiceBlack(t *testing.T) {
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceB)
if m.humanColor != NardyBlack {
t.Errorf("при выборе ColorChoiceB человек должен был играть чёрными, получено %v", m.humanColor)
}
}
func TestNewNardyModelRandomColorGivesValidColor(t *testing.T) {
seenWhite, seenBlack := false, false
for i := 0; i < 50 && !(seenWhite && seenBlack); i++ {
m := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceRandom)
if m.humanColor == NardyWhite {
seenWhite = true
} else {
seenBlack = true
}
}
if !seenWhite || !seenBlack {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
}
}

View file

@ -600,3 +600,43 @@ func TestStockReshufflesFromDiscardWhenEmpty(t *testing.T) {
t.Errorf("после перетасовки в сбросе не должно накопиться старых карт, получено %d карт", len(g.Discard))
}
}
// TestPendingEightInvariantHoldsAcrossManyFullGames — регрессионный
// стресс-тест: проверяет через множество полных партий, что
// canPlay всегда точно соответствует правилу "пока висит непокрытая
// восьмёрка, годится только карта её масти или другая восьмёрка".
// Написан после сообщения о подозрении, что требование покрытия
// не работает — сама логика оказалась корректной (проблема была в
// том, что интерфейс никак не показывал это требование игроку, см.
// oneohone.pending_eight_line), но тест оставлен как постоянная
// защита от регрессии этого конкретного правила.
func TestPendingEightInvariantHoldsAcrossManyFullGames(t *testing.T) {
bot := OneOhOneBot{}
for game := 0; game < 200; game++ {
g := NewOneOhOneGame([]string{"A", "B", "C"}, -1)
for turns := 0; turns < 2000 && g.Phase != OneOhOnePhaseRoundOver; turns++ {
if g.Phase == OneOhOnePhaseChooseSuit {
g.ChooseSuit(bot.DecideSuit(g))
continue
}
if pending := g.PendingEightSuit; pending != nil {
for _, card := range g.current().Hand {
want := card.Suit == *pending || card.Rank == Eight
if g.canPlay(card) != want {
t.Fatalf("партия %d, ход %d: canPlay(%v) при требовании покрыть масть %v — получено %v, ожидалось %v",
game, turns, card, *pending, g.canPlay(card), want)
}
}
}
if card, ok := bot.DecideAction(g); ok {
if err := g.PlayCard(card); err != nil {
t.Fatalf("партия %d, ход %d: неожиданная ошибка розыгрыша %v: %v", game, turns, card, err)
}
} else {
if err := g.Draw(); err != nil {
t.Fatalf("партия %d, ход %d: неожиданная ошибка добора: %v", game, turns, err)
}
}
}
}
}

View file

@ -208,15 +208,7 @@ func (m OneOhOneModel) renderHand() string {
if len(hand) == 0 {
return dimStyle.Render(T("common.no_cards"))
}
parts := make([]string, len(hand))
for i, c := range hand {
text := renderCard(c)
if i == m.cursor {
text = cursorStyle.Render(c.String())
}
parts[i] = text
}
return strings.Join(parts, " ")
return renderCardBoxHand(hand, cursorHighlights(len(hand), m.cursor))
}
func (m OneOhOneModel) renderPlayers() string {
@ -251,6 +243,12 @@ func (m OneOhOneModel) View() string {
fmt.Fprintln(&b, m.renderPlayers())
fmt.Fprintln(&b)
fmt.Fprintf(&b, T("oneohone.status_line"), renderCard(m.game.topDiscard()), len(m.game.Stock))
if m.game.PendingEightSuit != nil {
fmt.Fprintln(&b, errorStyle.Render(Tf("oneohone.pending_eight_line", m.game.PendingEightSuit.String())))
}
if m.game.DeclaredSuit != nil {
fmt.Fprintln(&b, errorStyle.Render(Tf("oneohone.declared_suit_line", m.game.DeclaredSuit.String())))
}
if m.game.Phase == OneOhOnePhaseRoundOver {
fmt.Fprintln(&b, m.renderResult())

View file

@ -2,6 +2,7 @@ package main
import (
"fmt"
"strings"
"testing"
)
@ -42,6 +43,75 @@ func TestOneOhOneModelPlayCardViaKeys(t *testing.T) {
}
}
func TestOneOhOneModelPendingEightVisibleInView(t *testing.T) {
m := NewOneOhOneModel(3)
suit := Diamonds
m.game.PendingEightSuit = &suit
view := m.View()
if !strings.Contains(view, suit.String()) {
t.Errorf("требование покрыть восьмёрку должно быть видно на экране, получено:\n%s", view)
}
}
func TestOneOhOneModelNoPendingEightLineWhenNil(t *testing.T) {
m := NewOneOhOneModel(3)
m.game.PendingEightSuit = nil
view := m.View()
if strings.Contains(view, "покрыть восьмёрку") {
t.Errorf("строка требования покрытия не должна показываться, когда PendingEightSuit == nil")
}
}
func TestOneOhOneModelDeclaredSuitVisibleInView(t *testing.T) {
m := NewOneOhOneModel(3)
suit := Hearts
m.game.DeclaredSuit = &suit
view := m.View()
if !strings.Contains(view, suit.String()) {
t.Errorf("объявленная масть должна быть видна на экране, получено:\n%s", view)
}
}
func TestOneOhOneModelNoDeclaredSuitLineWhenNil(t *testing.T) {
m := NewOneOhOneModel(3)
m.game.DeclaredSuit = nil
view := m.View()
if strings.Contains(view, "назвала масть") {
t.Errorf("строка объявленной масти не должна показываться, когда DeclaredSuit == nil")
}
}
func TestOneOhOneModelBotDeclaredSuitVisibleAfterQueen(t *testing.T) {
m := NewOneOhOneModel(3)
// подстраиваем ситуацию: сейчас ход бота, у него на руке дама,
// и сразу после розыгрыша должна открыться фаза выбора масти
botIdx := m.game.CurrentPlayerIdx
if botIdx == m.humanIdx {
botIdx = (botIdx + 1) % len(m.game.Players)
m.game.CurrentPlayerIdx = botIdx
}
m.game.Players[botIdx].Hand = []Card{c(Queen, Spades), c(Two, Clubs)}
m.game.Discard = []Card{c(King, Spades)}
m.game.PendingEightSuit = nil
m.game.DeclaredSuit = nil
next, _ := m.Update(botMoveMsg{})
m = next.(OneOhOneModel)
if m.game.Phase != OneOhOnePhaseChooseSuit {
t.Fatalf("ожидалась фаза выбора масти после розыгрыша дамы, получено %v", m.game.Phase)
}
next, _ = m.Update(botMoveMsg{})
m = next.(OneOhOneModel)
if m.game.DeclaredSuit == nil {
t.Fatalf("бот должен был назвать масть")
}
view := m.View()
if !strings.Contains(view, m.game.DeclaredSuit.String()) {
t.Errorf("названная ботом масть должна быть видна человеку на экране, получено:\n%s", view)
}
}
func TestOneOhOneModelDrawViaKeys(t *testing.T) {
m := NewOneOhOneModel(2)
m.game.Discard = []Card{c(Nine, Clubs)}

264
poker_bot.go Normal file
View file

@ -0,0 +1,264 @@
package main
import "math/rand"
// Бот Техасского Холдема — честного решателя GTO (оптимальной по
// теории игр стратегии) здесь нет и быть не может в разумном
// объёме кода; вместо этого используется оценка силы руки методом
// Монте-Карло (симуляция множества случайных завершений раздачи
// против случайных карт соперников) в сочетании с элементарным
// расчётом шансов банка. Это не идеальная игра — блеф и адаптация к
// стилю соперника учитываются лишь в грубом приближении — но
// решения основаны на честной оценке вероятности выигрыша, а не на
// произвольных правилах.
// PokerDifficulty — уровень сложности бота. Игроку выбирать
// сложность НЕ предлагается — при создании партии каждому боту
// случайно назначается один из трёх уровней (см. NewPokerModel).
type PokerDifficulty int
const (
PokerDifficultyEasy PokerDifficulty = iota
PokerDifficultyMedium
PokerDifficultyHard
)
// PokerBot — бот-соперник за одним из мест за столом.
type PokerBot struct {
Difficulty PokerDifficulty
}
// remainingDeck возвращает все карты колоды, ещё не известные как
// использованные (не в своей руке и не на столе) — для честной
// симуляции возможных карт соперников и оставшейся части борда.
func remainingDeck(hole [2]Card, board []Card) []Card {
used := map[Card]bool{hole[0]: true, hole[1]: true}
for _, c := range board {
used[c] = true
}
out := make([]Card, 0, 52-len(used))
for s := Clubs; s <= Spades; s++ {
for r := Ace; r <= King; r++ {
card := Card{Suit: s, Rank: r}
if !used[card] {
out = append(out, card)
}
}
}
return out
}
// pokerHandStrength оценивает вероятность выигрыша (или разделения
// банка — считается за половину) руки hole на текущем борде board
// против numOpponents случайных соперников, симулируя iterations
// случайных завершений раздачи.
func pokerHandStrength(hole [2]Card, board []Card, numOpponents, iterations int, rnd *rand.Rand) float64 {
if numOpponents <= 0 {
return 1
}
wins := 0.0
for i := 0; i < iterations; i++ {
deck := remainingDeck(hole, board)
rnd.Shuffle(len(deck), func(a, b int) { deck[a], deck[b] = deck[b], deck[a] })
pos := 0
oppHoles := make([][2]Card, numOpponents)
for o := 0; o < numOpponents; o++ {
oppHoles[o] = [2]Card{deck[pos], deck[pos+1]}
pos += 2
}
fullBoard := append([]Card{}, board...)
for len(fullBoard) < 5 {
fullBoard = append(fullBoard, deck[pos])
pos++
}
myValue := EvaluateBest5(append([]Card{hole[0], hole[1]}, fullBoard...))
winning, tie := true, false
for _, oh := range oppHoles {
ov := EvaluateBest5(append([]Card{oh[0], oh[1]}, fullBoard...))
switch {
case ComparePokerHands(ov, myValue) > 0:
winning = false
case ComparePokerHands(ov, myValue) == 0:
tie = true
}
if !winning {
break
}
}
if winning {
if tie {
wins += 0.5
} else {
wins += 1.0
}
}
}
return wins / float64(iterations)
}
// crudeStrength — грубая, не основанная на симуляции оценка силы
// руки для лёгкого уровня: до флопа — простая таблица по паре и
// старшинству карт, после флопа — приблизительная оценка по
// категории собранной комбинации (без учёта кикеров).
func crudeStrength(hole [2]Card, board []Card) float64 {
if len(board) == 0 {
r1, r2 := pokerRankValue(hole[0].Rank), pokerRankValue(hole[1].Rank)
if r1 == r2 {
return 0.5 + float64(r1)/28.0
}
high, low := r1, r2
if low > high {
high, low = low, high
}
strength := float64(high)/28.0 + float64(low)/56.0
if hole[0].Suit == hole[1].Suit {
strength += 0.05
}
if high-low <= 3 {
strength += 0.03
}
return strength
}
all := append([]Card{hole[0], hole[1]}, board...)
v := EvaluateBest5(all)
return 0.3 + float64(v.Category)/8.0*0.6
}
// potSize — сколько всего фишек уже внесено в банк за эту раздачу
// (по всем прошедшим раундам торгов вместе).
func (g *PokerGameState) potSize() int {
total := 0
for _, p := range g.Players {
total += p.TotalContributed
}
return total
}
// countOpponentsInHand — сколько соперников (кроме seatIdx) всё ещё
// не сбросили карты в этой раздаче.
func (g *PokerGameState) countOpponentsInHand(seatIdx int) int {
n := 0
for i, p := range g.Players {
if i != seatIdx && !p.Folded && !p.Eliminated {
n++
}
}
return n
}
// raiseTotalForStrength считает суммарную ставку раунда для рейза,
// примерно пропорциональную банку и силе руки (от половины банка до
// полного банка), с учётом ограничений минимального рейза и
// собственного стека.
func raiseTotalForStrength(g *PokerGameState, seatIdx int, strength float64) int {
p := g.Players[seatIdx]
pot := g.potSize()
raiseSize := int(float64(pot) * (0.5 + strength*0.5))
if raiseSize < g.BigBlind {
raiseSize = g.BigBlind
}
total := g.highestBet() + raiseSize
maxTotal := p.CurrentBet + p.Stack
if total > maxTotal {
total = maxTotal
}
minTotal := g.highestBet() + g.BigBlind
if g.highestBet() == 0 {
minTotal = g.BigBlind
}
if total < minTotal {
total = minTotal
}
if total > maxTotal {
total = maxTotal
}
return total
}
// PokerBotAction — решение бота на один ход.
type PokerBotAction struct {
Kind string // "fold", "check", "call", "bet"
BetAmount int // суммарная ставка раунда — только для "bet"
}
// DecideAction выбирает действие бота на его ходу seatIdx согласно
// уровню сложности.
func (b PokerBot) DecideAction(g *PokerGameState, seatIdx int, rnd *rand.Rand) PokerBotAction {
p := g.Players[seatIdx]
toCall := g.highestBet() - p.CurrentBet
if b.Difficulty == PokerDifficultyEasy {
return b.decideEasy(g, seatIdx, toCall)
}
return b.decideByStrength(g, seatIdx, toCall, rnd)
}
func (b PokerBot) decideEasy(g *PokerGameState, seatIdx int, toCall int) PokerBotAction {
p := g.Players[seatIdx]
strength := crudeStrength(p.HoleCards, g.Board)
if toCall == 0 {
if strength > 0.62 {
return PokerBotAction{Kind: "bet", BetAmount: raiseTotalForStrength(g, seatIdx, strength)}
}
return PokerBotAction{Kind: "check"}
}
callOdds := float64(toCall) / float64(g.potSize()+toCall)
if strength+0.25 >= callOdds || toCall <= g.BigBlind*2 {
return PokerBotAction{Kind: "call"}
}
return PokerBotAction{Kind: "fold"}
}
func (b PokerBot) decideByStrength(g *PokerGameState, seatIdx int, toCall int, rnd *rand.Rand) PokerBotAction {
p := g.Players[seatIdx]
iterations := 80
bluffChance := 0.0
aggression := 1.0
if b.Difficulty == PokerDifficultyHard {
iterations = 200
bluffChance = 0.1
aggression = 1.15
}
if bluffChance > 0 && rnd.Float64() < bluffChance {
return PokerBotAction{Kind: "bet", BetAmount: raiseTotalForStrength(g, seatIdx, 0.8)}
}
strength := pokerHandStrength(p.HoleCards, g.Board, g.countOpponentsInHand(seatIdx), iterations, rnd)
if toCall == 0 {
if strength > 0.65*aggression {
return PokerBotAction{Kind: "bet", BetAmount: raiseTotalForStrength(g, seatIdx, strength)}
}
return PokerBotAction{Kind: "check"}
}
potOddsNeeded := float64(toCall) / float64(g.potSize()+toCall)
if strength < potOddsNeeded {
return PokerBotAction{Kind: "fold"}
}
if strength > 0.75*aggression {
return PokerBotAction{Kind: "bet", BetAmount: raiseTotalForStrength(g, seatIdx, strength)}
}
return PokerBotAction{Kind: "call"}
}
// PlayFullTurn исполняет один ход бота на его месте seatIdx.
func (b PokerBot) PlayFullTurn(g *PokerGameState, seatIdx int, rnd *rand.Rand) error {
action := b.DecideAction(g, seatIdx, rnd)
switch action.Kind {
case "fold":
return g.Fold()
case "check":
return g.Check()
case "call":
return g.Call()
case "bet":
return g.Bet(action.BetAmount)
}
return nil
}

133
poker_bot_test.go Normal file
View file

@ -0,0 +1,133 @@
package main
import (
"math/rand"
"testing"
)
func TestPokerHandStrengthPocketAcesBeatsRandomMost(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
aces := [2]Card{c(Ace, Spades), c(Ace, Hearts)}
strength := pokerHandStrength(aces, nil, 1, 300, rnd)
if strength < 0.7 {
t.Errorf("пара тузов против одного случайного соперника должна выигрывать заметно чаще, чем в 70%% случаев, получено %.2f", strength)
}
}
func TestPokerHandStrengthWeakHandLowerThanPocketAces(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
aces := [2]Card{c(Ace, Spades), c(Ace, Hearts)}
weak := [2]Card{c(Two, Clubs), c(Seven, Diamonds)}
acesStrength := pokerHandStrength(aces, nil, 1, 300, rnd)
weakStrength := pokerHandStrength(weak, nil, 1, 300, rnd)
if weakStrength >= acesStrength {
t.Errorf("слабая рука 2-7 разномастные не должна оцениваться сильнее пары тузов: слабая=%.2f тузы=%.2f", weakStrength, acesStrength)
}
}
func TestCrudeStrengthPairHigherThanHighCardOnly(t *testing.T) {
pair := crudeStrength([2]Card{c(King, Clubs), c(King, Diamonds)}, nil)
highCard := crudeStrength([2]Card{c(Two, Clubs), c(Seven, Diamonds)}, nil)
if pair <= highCard {
t.Errorf("пара королей должна оцениваться выше, чем непарные 2-7, получено пара=%.2f старшая=%.2f", pair, highCard)
}
}
func TestRemainingDeckExcludesKnownCards(t *testing.T) {
hole := [2]Card{c(Ace, Spades), c(King, Hearts)}
board := []Card{c(Two, Clubs), c(Three, Diamonds), c(Four, Hearts)}
deck := remainingDeck(hole, board)
if len(deck) != 52-5 {
t.Fatalf("ожидалось 47 оставшихся карт, получено %d", len(deck))
}
known := map[Card]bool{hole[0]: true, hole[1]: true}
for _, cc := range board {
known[cc] = true
}
for _, card := range deck {
if known[card] {
t.Errorf("известная карта %v не должна встречаться в оставшейся колоде", card)
}
}
}
func playPokerFullTournament(t *testing.T, g *PokerGameState, bots []PokerBot, rnd *rand.Rand, maxHands int) {
t.Helper()
for hand := 0; hand < maxHands && g.Phase != PokerPhaseTournamentOver; hand++ {
steps := 0
for g.Phase != PokerPhaseShowdown && g.Phase != PokerPhaseTournamentOver && steps < 500 {
steps++
seat := g.CurrentPlayerIdx
if err := bots[seat].PlayFullTurn(g, seat, rnd); err != nil {
t.Fatalf("раздача %d, шаг %d: неожиданная ошибка бота на месте %d: %v", hand, steps, seat, err)
}
}
if g.Phase != PokerPhaseShowdown && g.Phase != PokerPhaseTournamentOver {
t.Fatalf("раздача %d не завершилась за %d шагов — похоже на зависание", hand, steps)
}
if g.Phase == PokerPhaseShowdown {
g.NextHand(rnd)
}
}
if g.Phase != PokerPhaseTournamentOver {
t.Fatalf("турнир не завершился за %d раздач", maxHands)
}
}
func TestPokerBotFullTournamentAllEasy(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
for trial := 0; trial < 3; trial++ {
g := NewPokerGame(4, 500, 10, 20, rnd)
bots := []PokerBot{{Difficulty: PokerDifficultyEasy}, {Difficulty: PokerDifficultyEasy}, {Difficulty: PokerDifficultyEasy}, {Difficulty: PokerDifficultyEasy}}
playPokerFullTournament(t, g, bots, rnd, 300)
}
}
func TestPokerBotFullTournamentMixedDifficulties(t *testing.T) {
rnd := rand.New(rand.NewSource(2))
for trial := 0; trial < 2; trial++ {
g := NewPokerGame(4, 500, 10, 20, rnd)
bots := []PokerBot{
{Difficulty: PokerDifficultyEasy},
{Difficulty: PokerDifficultyMedium},
{Difficulty: PokerDifficultyHard},
{Difficulty: PokerDifficultyMedium},
}
playPokerFullTournament(t, g, bots, rnd, 300)
}
}
func TestPokerBotFullTournamentHeadsUp(t *testing.T) {
rnd := rand.New(rand.NewSource(3))
for trial := 0; trial < 3; trial++ {
g := NewPokerGame(2, 500, 10, 20, rnd)
bots := []PokerBot{{Difficulty: PokerDifficultyHard}, {Difficulty: PokerDifficultyEasy}}
playPokerFullTournament(t, g, bots, rnd, 300)
}
}
func TestPokerBotFullTournamentSixPlayers(t *testing.T) {
rnd := rand.New(rand.NewSource(4))
g := NewPokerGame(6, 500, 10, 20, rnd)
bots := make([]PokerBot, 6)
for i := range bots {
bots[i] = PokerBot{Difficulty: PokerDifficulty(i % 3)}
}
playPokerFullTournament(t, g, bots, rnd, 500)
}
func TestPokerBotNeverActsWithInvalidAmount(t *testing.T) {
rnd := rand.New(rand.NewSource(5))
for trial := 0; trial < 5; trial++ {
g := NewPokerGame(3, 200, 10, 20, rnd)
bots := []PokerBot{{Difficulty: PokerDifficultyHard}, {Difficulty: PokerDifficultyHard}, {Difficulty: PokerDifficultyHard}}
steps := 0
for g.Phase != PokerPhaseShowdown && g.Phase != PokerPhaseTournamentOver && steps < 200 {
steps++
seat := g.CurrentPlayerIdx
if err := bots[seat].PlayFullTurn(g, seat, rnd); err != nil {
t.Fatalf("бот попытался сделать недопустимое действие: %v", err)
}
}
}
}

562
poker_game.go Normal file
View file

@ -0,0 +1,562 @@
package main
import (
"errors"
"math/rand"
"sort"
)
// Техасский Холдем (No-Limit) — турнирный формат с выбыванием: игра
// идёт до тех пор, пока не останется один игрок с фишками. Блайнды
// фиксированы (не растут по ходу турнира) — это сознательное
// упрощение ради управляемого объёма игры.
//
// ВАЖНОЕ УПРОЩЕНИЕ ПРАВИЛ: короткий олл-ин рейзом МЕНЬШЕ полного
// размера предыдущего рейза, по строгим правилам, не должен заново
// открывать торги игрокам, которые уже походили в этом раунде (они
// обязаны лишь доколлировать либо сбросить, но не имеют права
// повторно рейзить в ответ именно на такой недостаточный рейз). Эта
// игра такого разграничения не делает — любой рейз (в том числе
// короткий олл-ин) заново даёт всем активным игрокам право хода,
// включая повторный рейз. Расхождение с турнирными правилами
// возможно только в редких ситуациях с несколькими короткостёчными
// олл-инами подряд.
// PokerPhase — фаза текущей раздачи.
type PokerPhase int
const (
PokerPhasePreflop PokerPhase = iota
PokerPhaseFlop
PokerPhaseTurn
PokerPhaseRiver
PokerPhaseShowdown // раздача завершена, идёт показ карт/распределение банка
PokerPhaseTournamentOver // остался один игрок с фишками — турнир окончен
)
// PokerPlayer — один участник партии.
type PokerPlayer struct {
Name string
Stack int // фишки, НЕ поставленные в текущий банк
Bot *PokerBot
HoleCards [2]Card
Folded bool
AllIn bool
Eliminated bool // стек обнулился — выбыл из турнира насовсем
CurrentBet int // ставка ТЕКУЩЕГО раунда торгов (preflop/flop/turn/river)
TotalContributed int // сколько всего внесено в банк за ВСЮ раздачу (для сайд-потов)
}
// PokerSidePot — один "слой" банка с собственным списком, кто может
// на него претендовать (см. computeSidePots).
type PokerSidePot struct {
Amount int
EligiblePlayers []int
}
// PokerHandResult — итог завершённой раздачи (для отображения на
// экране шоудауна/сброса).
type PokerHandResult struct {
Pots []PokerSidePot
Winners map[int]int // индекс игрока -> сколько выиграл всего по всем банкам
WentToShowdown bool
HandValues map[int]PokerHandValue // только для тех, кто дошёл до шоудауна
}
// PokerGameState — состояние всей партии (турнира).
type PokerGameState struct {
Players []*PokerPlayer
Deck []Card
Board []Card
DealerIdx int
SmallBlind int
BigBlind int
Phase PokerPhase
CurrentPlayerIdx int
actedThisRound map[int]bool // кто уже отреагировал на ТЕКУЩИЙ уровень ставки в этом раунде торгов
lastAggressorIdx int // кто последний раз ставил/рейзил в этом раунде (-1, если раунд ещё без ставок)
Result *PokerHandResult
}
var (
ErrPokerWrongPhase = errors.New("недопустимое действие для текущей фазы")
ErrPokerNotYourTurn = errors.New("сейчас не ваш ход")
ErrPokerCannotCheck = errors.New("нельзя пасовать (чек) — на столе уже есть ставка, которую нужно уравнять или сбросить")
ErrPokerBetTooLow = errors.New("ставка меньше минимально допустимой")
ErrPokerNotEnoughChips = errors.New("недостаточно фишек для такой ставки")
)
// NewPokerGame создаёт новый турнир: заданное число игроков (первый
// — человек), фиксированный стартовый стек и блайнды.
func NewPokerGame(numPlayers, startingStack, smallBlind, bigBlind int, rnd *rand.Rand) *PokerGameState {
g := &PokerGameState{SmallBlind: smallBlind, BigBlind: bigBlind, DealerIdx: -1}
for i := 0; i < numPlayers; i++ {
g.Players = append(g.Players, &PokerPlayer{Stack: startingStack})
}
g.startNewHand(rnd)
return g
}
// activeTournamentPlayers — индексы игроков, ещё не выбывших из
// турнира.
func (g *PokerGameState) activeTournamentPlayers() []int {
var out []int
for i, p := range g.Players {
if !p.Eliminated {
out = append(out, i)
}
}
return out
}
// nextActiveTournamentSeat находит следующее по кругу от from место
// среди ещё не выбывших из турнира игроков.
func (g *PokerGameState) nextActiveTournamentSeat(from int) int {
n := len(g.Players)
for i := 1; i <= n; i++ {
idx := (from + i) % n
if !g.Players[idx].Eliminated {
return idx
}
}
return from
}
// nextToAct находит следующего игрока по кругу от from, который ещё
// может действовать в текущем раунде торгов (не сбросил, не в
// олл-ине, не выбыл).
func (g *PokerGameState) nextToAct(from int) int {
n := len(g.Players)
for i := 1; i <= n; i++ {
idx := (from + i) % n
p := g.Players[idx]
if !p.Folded && !p.AllIn && !p.Eliminated {
return idx
}
}
return from
}
func (g *PokerGameState) countActingPlayers() int {
n := 0
for _, p := range g.Players {
if !p.Folded && !p.AllIn && !p.Eliminated {
n++
}
}
return n
}
func (g *PokerGameState) countInHand() int {
n := 0
for _, p := range g.Players {
if !p.Folded && !p.Eliminated {
n++
}
}
return n
}
// startNewHand начинает новую раздачу: сдвигает дилера, раздаёт
// карты, ставит блайнды, определяет первый ход.
func (g *PokerGameState) startNewHand(rnd *rand.Rand) {
active := g.activeTournamentPlayers()
if len(active) <= 1 {
g.Phase = PokerPhaseTournamentOver
return
}
for _, p := range g.Players {
p.HoleCards = [2]Card{}
p.Folded = p.Eliminated
p.AllIn = false
p.CurrentBet = 0
p.TotalContributed = 0
}
g.Result = nil
g.Board = nil
g.Deck = shuffledDeck(rnd)
if g.DealerIdx < 0 {
g.DealerIdx = active[0]
} else {
g.DealerIdx = g.nextActiveTournamentSeat(g.DealerIdx)
}
// В игре один на один (heads-up) дилер ставит МАЛЫЙ блайнд сам
// (а не "следующий" игрок) — общее правило "SB = следующий после
// дилера" здесь даёт неверный результат, так как с двумя
// игроками "следующий после дилера" и "следующий после этого
// игрока" зацикливаются друг на друга, меняя роли блайндов местами.
var sbIdx, bbIdx int
if len(active) == 2 {
sbIdx = g.DealerIdx
bbIdx = g.nextActiveTournamentSeat(g.DealerIdx)
} else {
sbIdx = g.nextActiveTournamentSeat(g.DealerIdx)
bbIdx = g.nextActiveTournamentSeat(sbIdx)
}
g.postBlind(sbIdx, g.SmallBlind)
g.postBlind(bbIdx, g.BigBlind)
for i := 0; i < 2; i++ {
for _, idx := range g.activeTournamentPlayers() {
g.Players[idx].HoleCards[i] = g.drawCard()
}
}
g.Phase = PokerPhasePreflop
g.lastAggressorIdx = bbIdx
g.actedThisRound = map[int]bool{}
g.CurrentPlayerIdx = g.nextToAct(bbIdx)
}
func (g *PokerGameState) postBlind(idx, amount int) {
p := g.Players[idx]
if amount >= p.Stack {
amount = p.Stack
p.AllIn = true
}
p.Stack -= amount
p.CurrentBet = amount
p.TotalContributed = amount
}
func (g *PokerGameState) drawCard() Card {
card := g.Deck[0]
g.Deck = g.Deck[1:]
return card
}
func shuffledDeck(rnd *rand.Rand) []Card {
deck := make([]Card, 0, 52)
for s := Clubs; s <= Spades; s++ {
for r := Ace; r <= King; r++ {
deck = append(deck, Card{Suit: s, Rank: r})
}
}
rnd.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
return deck
}
// highestBet — самая большая ставка ЭТОГО раунда торгов среди всех
// игроков.
func (g *PokerGameState) highestBet() int {
max := 0
for _, p := range g.Players {
if p.CurrentBet > max {
max = p.CurrentBet
}
}
return max
}
// Fold — игрок сбрасывает карты.
func (g *PokerGameState) Fold() error {
if err := g.checkCanAct(); err != nil {
return err
}
g.Players[g.CurrentPlayerIdx].Folded = true
g.advanceAfterAction()
return nil
}
// Check — пропустить ход без ставки (легально только если нечего уравнивать).
func (g *PokerGameState) Check() error {
if err := g.checkCanAct(); err != nil {
return err
}
p := g.Players[g.CurrentPlayerIdx]
if p.CurrentBet != g.highestBet() {
return ErrPokerCannotCheck
}
g.advanceAfterAction()
return nil
}
// Call — уравнять текущую наибольшую ставку (либо пойти в олл-ин,
// если фишек не хватает на полное уравнивание).
func (g *PokerGameState) Call() error {
if err := g.checkCanAct(); err != nil {
return err
}
p := g.Players[g.CurrentPlayerIdx]
need := g.highestBet() - p.CurrentBet
if need <= 0 {
return g.Check()
}
if need >= p.Stack {
need = p.Stack
p.AllIn = true
}
p.Stack -= need
p.CurrentBet += need
p.TotalContributed += need
g.advanceAfterAction()
return nil
}
// Bet ставит (или рейзит) так, чтобы СУММАРНАЯ ставка игрока в этом
// раунде торгов стала равна totalAmount. totalAmount должен быть не
// меньше текущей наибольшей ставки плюс большой блайнд (минимальный
// рейз в этой упрощённой реализации — см. пояснение в начале файла).
func (g *PokerGameState) Bet(totalAmount int) error {
if err := g.checkCanAct(); err != nil {
return err
}
p := g.Players[g.CurrentPlayerIdx]
minTotal := g.highestBet() + g.BigBlind
if g.highestBet() == 0 {
minTotal = g.BigBlind
}
maxTotal := p.CurrentBet + p.Stack
if totalAmount > maxTotal {
return ErrPokerNotEnoughChips
}
if totalAmount < minTotal && totalAmount < maxTotal {
return ErrPokerBetTooLow
}
delta := totalAmount - p.CurrentBet
p.Stack -= delta
p.CurrentBet = totalAmount
p.TotalContributed += delta
if p.Stack == 0 {
p.AllIn = true
}
g.lastAggressorIdx = g.CurrentPlayerIdx
g.actedThisRound = map[int]bool{g.CurrentPlayerIdx: true}
g.advanceAfterAction()
return nil
}
// AllIn ставит весь оставшийся стек.
func (g *PokerGameState) AllIn() error {
if err := g.checkCanAct(); err != nil {
return err
}
p := g.Players[g.CurrentPlayerIdx]
return g.Bet(p.CurrentBet + p.Stack)
}
func (g *PokerGameState) checkCanAct() error {
if g.Phase == PokerPhaseShowdown || g.Phase == PokerPhaseTournamentOver {
return ErrPokerWrongPhase
}
p := g.Players[g.CurrentPlayerIdx]
if p.Folded || p.AllIn || p.Eliminated {
return ErrPokerNotYourTurn
}
return nil
}
// advanceAfterAction обрабатывает переход хода после действия
// текущего игрока: завершение раздачи (остался один не сбросивший),
// переход к следующей фазе (когда раунд торгов завершён), либо
// передачу хода следующему активному игроку.
func (g *PokerGameState) advanceAfterAction() {
if g.countInHand() == 1 {
g.resolveHandByFold()
return
}
if g.actedThisRound == nil {
g.actedThisRound = map[int]bool{}
}
g.actedThisRound[g.CurrentPlayerIdx] = true
allActed := true
for i, p := range g.Players {
if !p.Folded && !p.AllIn && !p.Eliminated && !g.actedThisRound[i] {
allActed = false
break
}
}
if allActed {
g.advancePhase()
return
}
g.CurrentPlayerIdx = g.nextToAct(g.CurrentPlayerIdx)
}
// advancePhase переходит к следующей фазе раздачи (флоп/тёрн/ривер/
// шоудаун), сбрасывая ставки раунда. Если активных (способных
// действовать) игроков не осталось вовсе (все, кроме максимум
// одного, в олл-ине) — карты дораздаются сразу до шоудауна без
// дальнейших торгов.
func (g *PokerGameState) advancePhase() {
for _, p := range g.Players {
p.CurrentBet = 0
}
g.actedThisRound = map[int]bool{}
switch g.Phase {
case PokerPhasePreflop:
g.Board = append(g.Board, g.drawCard(), g.drawCard(), g.drawCard())
g.Phase = PokerPhaseFlop
case PokerPhaseFlop:
g.Board = append(g.Board, g.drawCard())
g.Phase = PokerPhaseTurn
case PokerPhaseTurn:
g.Board = append(g.Board, g.drawCard())
g.Phase = PokerPhaseRiver
case PokerPhaseRiver:
g.resolveShowdown()
return
}
if g.countActingPlayers() <= 1 {
// все, кроме максимум одного, в олл-ине — дальше действовать
// некому, доигрываем карты до шоудауна автоматически
g.advancePhase()
return
}
g.lastAggressorIdx = -1
g.CurrentPlayerIdx = g.nextToAct(g.DealerIdx)
}
// resolveHandByFold завершает раздачу, когда все, кроме одного,
// сбросили карты — оставшийся игрок забирает весь банк без показа карт.
func (g *PokerGameState) resolveHandByFold() {
var winnerIdx int
for i, p := range g.Players {
if !p.Folded && !p.Eliminated {
winnerIdx = i
break
}
}
pots := computeSidePots(g.Players)
total := 0
for _, pot := range pots {
total += pot.Amount
}
g.Players[winnerIdx].Stack += total
g.Result = &PokerHandResult{
Pots: pots,
Winners: map[int]int{winnerIdx: total},
WentToShowdown: false,
}
g.Phase = PokerPhaseShowdown
g.markEliminated()
}
// resolveShowdown вскрывает карты и распределяет все банки (включая
// сайд-поты) между лучшими руками среди дошедших до конца.
func (g *PokerGameState) resolveShowdown() {
pots := computeSidePots(g.Players)
values := map[int]PokerHandValue{}
for i, p := range g.Players {
if !p.Folded && !p.Eliminated {
all := append([]Card{p.HoleCards[0], p.HoleCards[1]}, g.Board...)
values[i] = EvaluateBest5(all)
}
}
winnings := map[int]int{}
for _, pot := range pots {
var best []int
for _, idx := range pot.EligiblePlayers {
if len(best) == 0 {
best = []int{idx}
continue
}
cmp := ComparePokerHands(values[idx], values[best[0]])
if cmp > 0 {
best = []int{idx}
} else if cmp == 0 {
best = append(best, idx)
}
}
share := pot.Amount / len(best)
remainder := pot.Amount - share*len(best)
for i, idx := range best {
amt := share
if i == 0 {
amt += remainder // остаток от неровного деления — первому по очереди из победителей
}
winnings[idx] += amt
g.Players[idx].Stack += amt
}
}
g.Result = &PokerHandResult{
Pots: pots,
Winners: winnings,
WentToShowdown: true,
HandValues: values,
}
g.Phase = PokerPhaseShowdown
g.markEliminated()
}
func (g *PokerGameState) markEliminated() {
for _, p := range g.Players {
if p.Stack == 0 && !p.Eliminated {
p.Eliminated = true
}
}
}
// NextHand начинает следующую раздачу турнира (или переводит в
// PokerPhaseTournamentOver, если остался один игрок с фишками).
func (g *PokerGameState) NextHand(rnd *rand.Rand) {
if g.Phase != PokerPhaseShowdown {
return
}
g.startNewHand(rnd)
}
// computeSidePots раскладывает общий банк на "слои" по уровням
// взносов игроков за раздачу — стандартный алгоритм сайд-потов:
// каждый слой формируют все, кто внёс хотя бы до этого уровня
// (включая уже сбросивших — их фишки остаются в банке), а
// претендовать на слой могут только НЕ сбросившие игроки, дошедшие
// до этого уровня.
func computeSidePots(players []*PokerPlayer) []PokerSidePot {
levelSet := map[int]bool{}
for _, p := range players {
if p.TotalContributed > 0 {
levelSet[p.TotalContributed] = true
}
}
var levels []int
for lvl := range levelSet {
levels = append(levels, lvl)
}
sort.Ints(levels)
var pots []PokerSidePot
prev := 0
carry := 0
for _, level := range levels {
layerSize := level - prev
contributors := 0
var eligible []int
for i, p := range players {
if p.TotalContributed >= level {
contributors++
if !p.Folded {
eligible = append(eligible, i)
}
}
}
amount := layerSize*contributors + carry
if len(eligible) == 0 {
carry = amount
} else {
pots = append(pots, PokerSidePot{Amount: amount, EligiblePlayers: eligible})
carry = 0
}
prev = level
}
if carry > 0 && len(pots) > 0 {
pots[len(pots)-1].Amount += carry
}
return pots
}

287
poker_game_test.go Normal file
View file

@ -0,0 +1,287 @@
package main
import (
"math/rand"
"testing"
)
func newTestPokerGame(numPlayers, stack int) *PokerGameState {
rnd := rand.New(rand.NewSource(1))
return NewPokerGame(numPlayers, stack, 10, 20, rnd)
}
func TestNewPokerGamePostsBlindsCorrectly(t *testing.T) {
g := newTestPokerGame(3, 1000)
sbIdx := g.nextActiveTournamentSeat(g.DealerIdx)
bbIdx := g.nextActiveTournamentSeat(sbIdx)
if g.Players[sbIdx].CurrentBet != 10 {
t.Errorf("малый блайнд должен был поставить 10, получено %d", g.Players[sbIdx].CurrentBet)
}
if g.Players[bbIdx].CurrentBet != 20 {
t.Errorf("большой блайнд должен был поставить 20, получено %d", g.Players[bbIdx].CurrentBet)
}
if g.Players[sbIdx].Stack != 990 || g.Players[bbIdx].Stack != 980 {
t.Errorf("стеки после блайндов неверны: SB=%d BB=%d", g.Players[sbIdx].Stack, g.Players[bbIdx].Stack)
}
}
func TestHeadsUpDealerPostsSmallBlindHimself(t *testing.T) {
g := newTestPokerGame(2, 1000)
other := g.nextActiveTournamentSeat(g.DealerIdx)
if g.Players[g.DealerIdx].CurrentBet != g.SmallBlind {
t.Errorf("в игре один на один дилер должен сам поставить малый блайнд, получено %d", g.Players[g.DealerIdx].CurrentBet)
}
if g.Players[other].CurrentBet != g.BigBlind {
t.Errorf("в игре один на один второй игрок должен поставить большой блайнд, получено %d", g.Players[other].CurrentBet)
}
if g.CurrentPlayerIdx != g.DealerIdx {
t.Errorf("в игре один на один префлоп-ход первым должен ходить дилер (малый блайнд), получено игрока %d", g.CurrentPlayerIdx)
}
}
func TestEachPlayerGetsTwoHoleCards(t *testing.T) {
g := newTestPokerGame(4, 1000)
seen := map[Card]bool{}
for _, p := range g.Players {
if p.HoleCards[0] == p.HoleCards[1] {
t.Fatalf("две карты в руке одного игрока не должны совпадать: %v", p.HoleCards)
}
for _, c := range p.HoleCards {
if seen[c] {
t.Fatalf("карта %v оказалась у двух разных игроков", c)
}
seen[c] = true
}
}
}
func TestPreflopActionStartsAfterBigBlind(t *testing.T) {
g := newTestPokerGame(3, 1000)
bbIdx := g.nextActiveTournamentSeat(g.nextActiveTournamentSeat(g.DealerIdx))
want := g.nextToAct(bbIdx)
if g.CurrentPlayerIdx != want {
t.Errorf("первый ход префлопа должен быть у игрока после большого блайнда, ожидалось %d, получено %d", want, g.CurrentPlayerIdx)
}
}
func TestCheckRejectedWhenBetIsOutstanding(t *testing.T) {
g := newTestPokerGame(3, 1000)
if err := g.Check(); err != ErrPokerCannotCheck {
t.Errorf("ожидалась ошибка недопустимого чека против ставки большого блайнда, получено: %v", err)
}
}
func TestCallMatchesHighestBet(t *testing.T) {
g := newTestPokerGame(3, 1000)
actor := g.CurrentPlayerIdx
if err := g.Call(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if g.Players[actor].CurrentBet != 20 {
t.Errorf("после колла ставка должна была стать 20, получено %d", g.Players[actor].CurrentBet)
}
}
func TestBetBelowMinimumRejected(t *testing.T) {
g := newTestPokerGame(3, 1000)
if err := g.Bet(25); err != ErrPokerBetTooLow {
t.Errorf("ожидалась ошибка слишком маленькой ставки (минимум 40 = 20+20), получено: %v", err)
}
}
func TestBetAboveStackRejected(t *testing.T) {
g := newTestPokerGame(3, 1000)
if err := g.Bet(100000); err != ErrPokerNotEnoughChips {
t.Errorf("ожидалась ошибка нехватки фишек, получено: %v", err)
}
}
func TestCallWithInsufficientStackGoesAllIn(t *testing.T) {
g := newTestPokerGame(3, 15)
actor := g.CurrentPlayerIdx
if err := g.Call(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if !g.Players[actor].AllIn {
t.Errorf("колл с недостаточным стеком должен был перевести игрока в олл-ин")
}
if g.Players[actor].Stack != 0 {
t.Errorf("после олл-ина стек должен был обнулиться, получено %d", g.Players[actor].Stack)
}
}
func TestFoldToOnePlayerEndsHandImmediately(t *testing.T) {
g := newTestPokerGame(3, 1000)
for g.Phase != PokerPhaseShowdown && g.countInHand() > 1 {
if err := g.Fold(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
if g.Phase != PokerPhaseShowdown {
t.Fatalf("раздача должна была завершиться после того, как остался один игрок")
}
if g.Result == nil || g.Result.WentToShowdown {
t.Errorf("завершение сбросами не должно доходить до шоудауна с показом карт")
}
}
func TestBettingRoundAdvancesToFlopWhenAllCall(t *testing.T) {
g := newTestPokerGame(3, 1000)
for g.Phase == PokerPhasePreflop {
if err := g.Call(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
}
if g.Phase != PokerPhaseFlop {
t.Fatalf("после того как все уравняли, должен был начаться флоп, получено %v", g.Phase)
}
if len(g.Board) != 3 {
t.Errorf("на флопе должно быть 3 общие карты, получено %d", len(g.Board))
}
}
func TestFullHandProgressesThroughAllStreets(t *testing.T) {
g := newTestPokerGame(2, 1000)
steps := 0
for g.Phase != PokerPhaseShowdown && steps < 100 {
steps++
if err := g.Check(); err != nil {
g.Call()
}
}
if g.Phase != PokerPhaseShowdown {
t.Fatalf("раздача не дошла до шоудауна за %d шагов", steps)
}
if len(g.Board) != 5 {
t.Errorf("на шоудауне должно быть 5 общих карт, получено %d", len(g.Board))
}
}
func TestAllInSkipsRemainingBettingRounds(t *testing.T) {
g := newTestPokerGame(2, 100)
if err := g.AllIn(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if err := g.Call(); err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if g.Phase != PokerPhaseShowdown {
t.Fatalf("после олл-ина и колла (никто больше не может действовать) должен был сразу настать шоудаун, получено %v", g.Phase)
}
if len(g.Board) != 5 {
t.Errorf("на доске должно было оказаться все 5 карт (доигранных без торгов), получено %d", len(g.Board))
}
}
func TestShowdownAwardsPotToBestHand(t *testing.T) {
g := newTestPokerGame(2, 1000)
g.Players[0].HoleCards = [2]Card{c(Ace, Spades), c(Ace, Hearts)}
g.Players[1].HoleCards = [2]Card{c(Two, Clubs), c(Seven, Diamonds)}
g.Board = []Card{c(Ace, Clubs), c(King, Spades), c(Queen, Hearts), c(Four, Diamonds), c(Nine, Clubs)}
g.Players[0].TotalContributed = 100
g.Players[1].TotalContributed = 100
g.resolveShowdown()
if g.Result.Winners[0] <= 0 {
t.Errorf("игрок 0 с тройкой тузов должен был выиграть банк, получено %+v", g.Result.Winners)
}
if _, lost := g.Result.Winners[1]; lost {
t.Errorf("игрок 1 не должен был получить долю банка")
}
}
func TestShowdownSplitsPotOnTie(t *testing.T) {
g := newTestPokerGame(2, 1000)
g.Players[0].HoleCards = [2]Card{c(Two, Clubs), c(Three, Diamonds)}
g.Players[1].HoleCards = [2]Card{c(Two, Hearts), c(Three, Spades)}
g.Board = []Card{c(Ace, Clubs), c(King, Spades), c(Queen, Hearts), c(Jack, Diamonds), c(Nine, Clubs)}
g.Players[0].TotalContributed = 100
g.Players[1].TotalContributed = 100
g.resolveShowdown()
if g.Result.Winners[0] != 100 || g.Result.Winners[1] != 100 {
t.Errorf("одинаковые по силе руки (игра по доске) должны были поровну разделить банк 200, получено %+v", g.Result.Winners)
}
}
func TestSidePotsWithTwoDifferentAllInAmounts(t *testing.T) {
players := []*PokerPlayer{
{TotalContributed: 50},
{TotalContributed: 100},
{TotalContributed: 100},
}
pots := computeSidePots(players)
if len(pots) != 2 {
t.Fatalf("ожидалось 2 банка (основной + один сайд-пот), получено %d: %+v", len(pots), pots)
}
if pots[0].Amount != 150 {
t.Errorf("основной банк должен быть 150 (50 от каждого из трёх), получено %d", pots[0].Amount)
}
if len(pots[0].EligiblePlayers) != 3 {
t.Errorf("на основной банк претендуют все трое, получено %d", len(pots[0].EligiblePlayers))
}
if pots[1].Amount != 100 {
t.Errorf("сайд-пот должен быть 100 (по 50 от двоих, кто пошёл дальше), получено %d", pots[1].Amount)
}
if len(pots[1].EligiblePlayers) != 2 {
t.Errorf("на сайд-пот претендуют только двое, получено %d", len(pots[1].EligiblePlayers))
}
}
func TestSidePotsExcludeFoldedPlayersFromEligibilityButKeepTheirMoney(t *testing.T) {
players := []*PokerPlayer{
{TotalContributed: 100, Folded: true},
{TotalContributed: 100},
{TotalContributed: 100},
}
pots := computeSidePots(players)
if len(pots) != 1 {
t.Fatalf("ожидался один общий банк, получено %d", len(pots))
}
if pots[0].Amount != 300 {
t.Errorf("деньги сбросившего игрока должны были остаться в банке, ожидалось 300, получено %d", pots[0].Amount)
}
if len(pots[0].EligiblePlayers) != 2 {
t.Errorf("сбросивший игрок не должен претендовать на банк, ожидалось 2 претендента, получено %d", len(pots[0].EligiblePlayers))
}
}
func TestPlayerEliminatedWhenStackHitsZero(t *testing.T) {
g := newTestPokerGame(2, 1000)
g.Players[0].HoleCards = [2]Card{c(Two, Clubs), c(Seven, Diamonds)}
g.Players[1].HoleCards = [2]Card{c(Ace, Spades), c(Ace, Hearts)}
g.Board = []Card{c(Ace, Clubs), c(King, Spades), c(Queen, Hearts), c(Four, Diamonds), c(Nine, Clubs)}
g.Players[0].Stack = 0
g.Players[0].TotalContributed = 1000
g.Players[1].TotalContributed = 1000
g.resolveShowdown()
if !g.Players[0].Eliminated {
t.Errorf("проигравший всё игрок должен был выбыть из турнира")
}
}
func TestTournamentOverWhenOnePlayerRemains(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
g := newTestPokerGame(2, 1000)
g.Players[0].Eliminated = true
g.Players[0].Stack = 0
g.Phase = PokerPhaseShowdown
g.NextHand(rnd)
if g.Phase != PokerPhaseTournamentOver {
t.Errorf("турнир должен был завершиться при одном оставшемся игроке, получено %v", g.Phase)
}
}
func TestDealerButtonRotatesEachHand(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
g := newTestPokerGame(3, 1000)
firstDealer := g.DealerIdx
for g.Phase != PokerPhaseShowdown {
g.Fold()
}
g.NextHand(rnd)
if g.DealerIdx == firstDealer {
t.Errorf("дилер должен был смениться на следующей раздаче")
}
}

188
poker_hand.go Normal file
View file

@ -0,0 +1,188 @@
package main
import "sort"
// PokerHandCategory — категория покерной комбинации (чем больше,
// тем сильнее).
type PokerHandCategory int
const (
PokerHighCard PokerHandCategory = iota
PokerPair
PokerTwoPair
PokerThreeOfAKind
PokerStraight
PokerFlush
PokerFullHouse
PokerFourOfAKind
PokerStraightFlush
)
// PokerHandValue — сравнимая оценка одной пятикарточной комбинации:
// категория плюс до пяти значений для сравнения кикеров, в порядке
// значимости (не все пять слотов задействованы каждой категорией —
// неиспользуемые остаются нулевыми, что не мешает сравнению, так
// как категория и более значимые слоты уже решают дело раньше).
type PokerHandValue struct {
Category PokerHandCategory
Tiebreak [5]int
}
// ComparePokerHands возвращает 1, если a сильнее b, -1, если b
// сильнее a, и 0 при полном равенстве (сплит банка).
func ComparePokerHands(a, b PokerHandValue) int {
if a.Category != b.Category {
if a.Category > b.Category {
return 1
}
return -1
}
for i := 0; i < 5; i++ {
if a.Tiebreak[i] != b.Tiebreak[i] {
if a.Tiebreak[i] > b.Tiebreak[i] {
return 1
}
return -1
}
}
return 0
}
// pokerRankValue переводит ранг карты в покерное значение (2-14,
// туз всегда старший) — в отличие от Rank.Value(), используемого
// для Тонка/Дурака, где туз равен 1.
func pokerRankValue(r Rank) int {
if r == Ace {
return 14
}
return int(r)
}
// EvaluateBest5 перебирает все сочетания по 5 карт из переданных
// (обычно 5 или 7 — например, 2 карты в руке + 5 общих) и возвращает
// оценку наилучшей возможной комбинации.
func EvaluateBest5(cards []Card) PokerHandValue {
if len(cards) == 5 {
return evaluate5(cards)
}
best := PokerHandValue{}
first := true
combinations5(cards, func(combo []Card) {
v := evaluate5(combo)
if first || ComparePokerHands(v, best) > 0 {
best = v
first = false
}
})
return best
}
// combinations5 вызывает fn для каждого сочетания по 5 карт из cards.
func combinations5(cards []Card, fn func(combo []Card)) {
n := len(cards)
idx := make([]int, 5)
for i := range idx {
idx[i] = i
}
for {
combo := make([]Card, 5)
for i, ix := range idx {
combo[i] = cards[ix]
}
fn(combo)
// следующая комбинация индексов (стандартный алгоритм перебора сочетаний)
i := 4
for i >= 0 && idx[i] == n-5+i {
i--
}
if i < 0 {
return
}
idx[i]++
for j := i + 1; j < 5; j++ {
idx[j] = idx[j-1] + 1
}
}
}
// evaluate5 оценивает ровно пять карт.
func evaluate5(cards []Card) PokerHandValue {
values := make([]int, 5)
suitCounts := map[Suit]int{}
for i, c := range cards {
values[i] = pokerRankValue(c.Rank)
suitCounts[c.Suit]++
}
sort.Sort(sort.Reverse(sort.IntSlice(values)))
isFlush := len(suitCounts) == 1
straightHigh, isStraight := detectStraight(values)
if isStraight && isFlush {
return PokerHandValue{Category: PokerStraightFlush, Tiebreak: [5]int{straightHigh}}
}
// группируем по количеству повторений ранга
countByRank := map[int]int{}
for _, v := range values {
countByRank[v]++
}
type rankCount struct{ rank, count int }
var groups []rankCount
for r, c := range countByRank {
groups = append(groups, rankCount{r, c})
}
sort.Slice(groups, func(i, j int) bool {
if groups[i].count != groups[j].count {
return groups[i].count > groups[j].count
}
return groups[i].rank > groups[j].rank
})
switch {
case groups[0].count == 4:
kicker := groups[1].rank
return PokerHandValue{Category: PokerFourOfAKind, Tiebreak: [5]int{groups[0].rank, kicker}}
case groups[0].count == 3 && groups[1].count == 2:
return PokerHandValue{Category: PokerFullHouse, Tiebreak: [5]int{groups[0].rank, groups[1].rank}}
case isFlush:
var tb [5]int
copy(tb[:], values)
return PokerHandValue{Category: PokerFlush, Tiebreak: tb}
case isStraight:
return PokerHandValue{Category: PokerStraight, Tiebreak: [5]int{straightHigh}}
case groups[0].count == 3:
kickers := []int{groups[1].rank, groups[2].rank}
return PokerHandValue{Category: PokerThreeOfAKind, Tiebreak: [5]int{groups[0].rank, kickers[0], kickers[1]}}
case groups[0].count == 2 && groups[1].count == 2:
hiPair, loPair := groups[0].rank, groups[1].rank
if loPair > hiPair {
hiPair, loPair = loPair, hiPair
}
return PokerHandValue{Category: PokerTwoPair, Tiebreak: [5]int{hiPair, loPair, groups[2].rank}}
case groups[0].count == 2:
return PokerHandValue{Category: PokerPair, Tiebreak: [5]int{groups[0].rank, groups[1].rank, groups[2].rank, groups[3].rank}}
default:
var tb [5]int
copy(tb[:], values)
return PokerHandValue{Category: PokerHighCard, Tiebreak: tb}
}
}
// detectStraight проверяет пять ОТСОРТИРОВАННЫХ ПО УБЫВАНИЮ
// покерных значений на стрит, включая особый случай "колеса"
// (A-5-4-3-2, где туз считается младшей картой, а старшая карта
// стрита — пятёрка).
func detectStraight(sortedDesc []int) (high int, ok bool) {
// особый случай колеса: A,5,4,3,2
if sortedDesc[0] == 14 && sortedDesc[1] == 5 && sortedDesc[2] == 4 && sortedDesc[3] == 3 && sortedDesc[4] == 2 {
return 5, true
}
for i := 0; i < 4; i++ {
if sortedDesc[i]-sortedDesc[i+1] != 1 {
return 0, false
}
}
return sortedDesc[0], true
}

150
poker_hand_test.go Normal file
View file

@ -0,0 +1,150 @@
package main
import "testing"
func TestEvaluate5Categories(t *testing.T) {
tests := []struct {
name string
cards []Card
want PokerHandCategory
}{
{"роял-флеш", []Card{c(Ten, Spades), c(Jack, Spades), c(Queen, Spades), c(King, Spades), c(Ace, Spades)}, PokerStraightFlush},
{"стрит-флеш", []Card{c(Five, Hearts), c(Six, Hearts), c(Seven, Hearts), c(Eight, Hearts), c(Nine, Hearts)}, PokerStraightFlush},
{"каре", []Card{c(Nine, Clubs), c(Nine, Diamonds), c(Nine, Hearts), c(Nine, Spades), c(Two, Clubs)}, PokerFourOfAKind},
{"фулл-хаус", []Card{c(King, Clubs), c(King, Diamonds), c(King, Hearts), c(Two, Spades), c(Two, Clubs)}, PokerFullHouse},
{"флеш", []Card{c(Two, Hearts), c(Five, Hearts), c(Seven, Hearts), c(Nine, Hearts), c(King, Hearts)}, PokerFlush},
{"стрит", []Card{c(Four, Clubs), c(Five, Diamonds), c(Six, Hearts), c(Seven, Spades), c(Eight, Clubs)}, PokerStraight},
{"колесо (A-5 стрит)", []Card{c(Ace, Clubs), c(Two, Diamonds), c(Three, Hearts), c(Four, Spades), c(Five, Clubs)}, PokerStraight},
{"тройка", []Card{c(Jack, Clubs), c(Jack, Diamonds), c(Jack, Hearts), c(Two, Spades), c(Nine, Clubs)}, PokerThreeOfAKind},
{"две пары", []Card{c(Ten, Clubs), c(Ten, Diamonds), c(Four, Hearts), c(Four, Spades), c(Nine, Clubs)}, PokerTwoPair},
{"пара", []Card{c(Eight, Clubs), c(Eight, Diamonds), c(Two, Hearts), c(Five, Spades), c(Nine, Clubs)}, PokerPair},
{"старшая карта", []Card{c(Two, Clubs), c(Five, Diamonds), c(Seven, Hearts), c(Nine, Spades), c(King, Clubs)}, PokerHighCard},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := evaluate5(tt.cards)
if got.Category != tt.want {
t.Errorf("ожидалась категория %v, получено %v (карты: %v)", tt.want, got.Category, tt.cards)
}
})
}
}
func TestWheelStraightRanksAsFive(t *testing.T) {
wheel := evaluate5([]Card{c(Ace, Clubs), c(Two, Diamonds), c(Three, Hearts), c(Four, Spades), c(Five, Clubs)})
if wheel.Category != PokerStraight || wheel.Tiebreak[0] != 5 {
t.Fatalf("колесо A-2-3-4-5 должно оцениваться как стрит со старшей картой 5, получено %+v", wheel)
}
sixHighStraight := evaluate5([]Card{c(Two, Clubs), c(Three, Diamonds), c(Four, Hearts), c(Five, Spades), c(Six, Clubs)})
if ComparePokerHands(sixHighStraight, wheel) <= 0 {
t.Errorf("стрит 2-6 должен быть старше колеса A-5")
}
}
func TestNotAStraightWhenWheelBrokenByAceHigh(t *testing.T) {
notStraight := evaluate5([]Card{c(Ace, Clubs), c(Two, Diamonds), c(Three, Hearts), c(Four, Spades), c(King, Clubs)})
if notStraight.Category == PokerStraight || notStraight.Category == PokerStraightFlush {
t.Errorf("A,2,3,4,K не должно засчитываться как стрит, получено %v", notStraight.Category)
}
}
func TestRoyalFlushBeatsStraightFlush(t *testing.T) {
royal := evaluate5([]Card{c(Ten, Spades), c(Jack, Spades), c(Queen, Spades), c(King, Spades), c(Ace, Spades)})
lower := evaluate5([]Card{c(Five, Hearts), c(Six, Hearts), c(Seven, Hearts), c(Eight, Hearts), c(Nine, Hearts)})
if ComparePokerHands(royal, lower) <= 0 {
t.Errorf("роял-флеш должен быть строго сильнее стрит-флеша 5-9")
}
}
func TestFourOfAKindKickerBreaksTie(t *testing.T) {
a := evaluate5([]Card{c(Nine, Clubs), c(Nine, Diamonds), c(Nine, Hearts), c(Nine, Spades), c(King, Clubs)})
b := evaluate5([]Card{c(Nine, Clubs), c(Nine, Diamonds), c(Nine, Hearts), c(Nine, Spades), c(Two, Clubs)})
if ComparePokerHands(a, b) <= 0 {
t.Errorf("одинаковое каре девяток с кикером королём должно побеждать кикер двойку")
}
}
func TestFullHouseTripsMatterMoreThanPair(t *testing.T) {
a := evaluate5([]Card{c(Two, Clubs), c(Two, Diamonds), c(Two, Hearts), c(King, Spades), c(King, Clubs)})
b := evaluate5([]Card{c(Queen, Clubs), c(Queen, Diamonds), c(Queen, Hearts), c(Ace, Spades), c(Ace, Clubs)})
if ComparePokerHands(b, a) <= 0 {
t.Errorf("фулл-хаус на тройке дам должен побеждать фулл-хаус на тройке двоек, несмотря на более слабую пару")
}
}
func TestTwoPairComparesHigherPairFirst(t *testing.T) {
a := evaluate5([]Card{c(King, Clubs), c(King, Diamonds), c(Two, Hearts), c(Two, Spades), c(Nine, Clubs)})
b := evaluate5([]Card{c(Queen, Clubs), c(Queen, Diamonds), c(Jack, Hearts), c(Jack, Spades), c(Ace, Clubs)})
if ComparePokerHands(a, b) <= 0 {
t.Errorf("две пары королей и двоек должны побеждать пары дам и валетов, несмотря на более слабый кикер")
}
}
func TestTwoPairKickerBreaksExactTie(t *testing.T) {
a := evaluate5([]Card{c(King, Clubs), c(King, Diamonds), c(Two, Hearts), c(Two, Spades), c(Ace, Clubs)})
b := evaluate5([]Card{c(King, Hearts), c(King, Spades), c(Two, Clubs), c(Two, Diamonds), c(Nine, Hearts)})
if ComparePokerHands(a, b) <= 0 {
t.Errorf("при равных парах решает кикер — туз должен побеждать девятку")
}
}
func TestPairComparesKickersInOrder(t *testing.T) {
a := evaluate5([]Card{c(Eight, Clubs), c(Eight, Diamonds), c(Ace, Hearts), c(Two, Spades), c(Three, Clubs)})
b := evaluate5([]Card{c(Eight, Hearts), c(Eight, Spades), c(King, Clubs), c(Queen, Diamonds), c(Jack, Hearts)})
if ComparePokerHands(a, b) <= 0 {
t.Errorf("при равных парах восьмёрок кикер-туз должен побеждать набор король-дама-валет")
}
}
func TestFlushComparesAllFiveCardsInOrder(t *testing.T) {
a := evaluate5([]Card{c(Two, Hearts), c(Five, Hearts), c(Seven, Hearts), c(Nine, Hearts), c(Ace, Hearts)})
b := evaluate5([]Card{c(Two, Spades), c(Five, Spades), c(Seven, Spades), c(Nine, Spades), c(King, Spades)})
if ComparePokerHands(a, b) <= 0 {
t.Errorf("флеш с тузом должен побеждать флеш с королём при прочих равных")
}
}
func TestIdenticalHandsSplitPot(t *testing.T) {
a := evaluate5([]Card{c(Two, Hearts), c(Five, Hearts), c(Seven, Hearts), c(Nine, Hearts), c(King, Hearts)})
b := evaluate5([]Card{c(Two, Spades), c(Five, Spades), c(Seven, Spades), c(Nine, Spades), c(King, Spades)})
if ComparePokerHands(a, b) != 0 {
t.Errorf("одинаковые по значению флеши разных мастей должны оцениваться как полностью равные (сплит банка), получено a=%+v b=%+v", a, b)
}
}
func TestEvaluateBest5ChoosesBestOfSeven(t *testing.T) {
hole := []Card{c(Ace, Spades), c(Ace, Hearts)}
board := []Card{c(Ace, Clubs), c(Ace, Diamonds), c(King, Spades), c(Two, Hearts), c(Three, Clubs)}
all := append(append([]Card{}, hole...), board...)
best := EvaluateBest5(all)
if best.Category != PokerFourOfAKind {
t.Fatalf("ожидалось каре тузов из семи карт, получено %v", best.Category)
}
if best.Tiebreak[0] != 14 || best.Tiebreak[1] != 13 {
t.Errorf("кикером каре тузов должен был стать король (13), получено %+v", best.Tiebreak)
}
}
func TestEvaluateBest5IgnoresWorseFiveCardSubset(t *testing.T) {
hole := []Card{c(Two, Hearts), c(Nine, Hearts)}
board := []Card{c(Five, Hearts), c(Six, Hearts), c(Seven, Clubs), c(Eight, Diamonds), c(King, Hearts)}
all := append(append([]Card{}, hole...), board...)
best := EvaluateBest5(all)
if best.Category != PokerFlush {
t.Fatalf("ожидался флеш как лучшая комбинация из семи карт, получено %v", best.Category)
}
}
func TestCombinations5Count(t *testing.T) {
cards := make([]Card, 7)
for i := range cards {
cards[i] = c(Rank(2+i), Clubs)
}
count := 0
combinations5(cards, func(combo []Card) { count++ })
if count != 21 {
t.Errorf("ожидалось ровно 21 сочетание из 7 по 5, получено %d", count)
}
}

423
poker_tui.go Normal file
View file

@ -0,0 +1,423 @@
package main
import (
"fmt"
"math/rand"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// PokerModel — TUI поверх PokerGameState. Человек всегда занимает
// место 0; каждому боту при создании партии случайно достаётся один
// из трёх уровней сложности — выбирать сложность игроку не
// предлагается специально (так и было задумано).
type PokerModel struct {
game *PokerGameState
bots map[int]PokerBot
humanSeat int
rng *rand.Rand
choosingBetAmount bool
betAmount int
message string
isError bool
quitting bool
backToMenu bool
}
const (
pokerStartingStack = 1000
pokerSmallBlind = 10
pokerBigBlind = 20
)
// NewPokerModel начинает новый турнир на numPlayers мест. Человек —
// место 0; остальным местам случайно назначается один из трёх
// уровней сложности бота.
func NewPokerModel(numPlayers int) PokerModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
game := NewPokerGame(numPlayers, pokerStartingStack, pokerSmallBlind, pokerBigBlind, rng)
bots := map[int]PokerBot{}
for i := 1; i < numPlayers; i++ {
bots[i] = PokerBot{Difficulty: PokerDifficulty(rng.Intn(3))}
}
return PokerModel{game: game, bots: bots, humanSeat: 0, rng: rng}
}
func (m PokerModel) Init() tea.Cmd {
return m.maybeScheduleBot()
}
func (m PokerModel) maybeScheduleBot() tea.Cmd {
g := m.game
if g.Phase == PokerPhaseTournamentOver || g.Phase == PokerPhaseShowdown {
return nil
}
if g.CurrentPlayerIdx == m.humanSeat {
return nil
}
return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} })
}
func (m *PokerModel) setInfo(key string) {
m.message = T(key)
m.isError = false
}
func (m *PokerModel) setError(err error) {
m.message = err.Error()
m.isError = true
}
func (m PokerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case botMoveMsg:
return m.handleBotMove()
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m PokerModel) handleBotMove() (tea.Model, tea.Cmd) {
g := m.game
if g.Phase == PokerPhaseTournamentOver || g.Phase == PokerPhaseShowdown {
return m, nil
}
if g.CurrentPlayerIdx == m.humanSeat {
return m, nil
}
seat := g.CurrentPlayerIdx
bot := m.bots[seat]
if err := bot.PlayFullTurn(g, seat, m.rng); err != nil {
m.setError(err)
return m, nil
}
m.message = ""
return m, m.maybeScheduleBot()
}
// pokerHumanActions строит пронумерованный список действий,
// доступных человеку прямо сейчас.
func (m PokerModel) pokerHumanActions() []string {
g := m.game
p := g.Players[m.humanSeat]
toCall := g.highestBet() - p.CurrentBet
var actions []string
actions = append(actions, "fold")
if toCall == 0 {
actions = append(actions, "check")
} else {
actions = append(actions, "call")
}
if p.Stack > 0 {
actions = append(actions, "bet")
}
actions = append(actions, "allin")
return actions
}
func (m PokerModel) 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
}
g := m.game
if g.Phase == PokerPhaseTournamentOver {
return m, nil
}
if g.Phase == PokerPhaseShowdown {
if key == "n" || key == "enter" {
g.NextHand(m.rng)
m.message = ""
return m, m.maybeScheduleBot()
}
return m, nil
}
if g.CurrentPlayerIdx != m.humanSeat {
return m, nil
}
if m.choosingBetAmount {
return m.handleBetAmountKey(key)
}
actions := m.pokerHumanActions()
for i, a := range actions {
if key != fmt.Sprintf("%d", i+1) {
continue
}
return m.applyActionLabel(a)
}
return m, nil
}
func (m PokerModel) applyActionLabel(label string) (tea.Model, tea.Cmd) {
g := m.game
p := g.Players[m.humanSeat]
switch label {
case "fold":
if err := g.Fold(); err != nil {
m.setError(err)
return m, nil
}
case "check":
if err := g.Check(); err != nil {
m.setError(err)
return m, nil
}
case "call":
if err := g.Call(); err != nil {
m.setError(err)
return m, nil
}
case "bet":
m.choosingBetAmount = true
minTotal := g.highestBet() + g.BigBlind
if g.highestBet() == 0 {
minTotal = g.BigBlind
}
maxTotal := p.CurrentBet + p.Stack
if minTotal > maxTotal {
minTotal = maxTotal
}
m.betAmount = minTotal
return m, nil
case "allin":
if err := g.AllIn(); err != nil {
m.setError(err)
return m, nil
}
}
m.message = ""
if g.Phase == PokerPhaseShowdown || g.Phase == PokerPhaseTournamentOver {
return m, nil
}
return m, m.maybeScheduleBot()
}
func (m PokerModel) handleBetAmountKey(key string) (tea.Model, tea.Cmd) {
g := m.game
p := g.Players[m.humanSeat]
maxTotal := p.CurrentBet + p.Stack
minTotal := g.highestBet() + g.BigBlind
if g.highestBet() == 0 {
minTotal = g.BigBlind
}
if minTotal > maxTotal {
minTotal = maxTotal
}
switch key {
case "esc":
m.choosingBetAmount = false
return m, nil
case "up", "k":
m.betAmount += g.BigBlind
case "down", "j":
m.betAmount -= g.BigBlind
case "right", "l":
m.betAmount += g.BigBlind * 5
case "left", "h":
m.betAmount -= g.BigBlind * 5
case "enter":
if err := g.Bet(m.betAmount); err != nil {
m.setError(err)
return m, nil
}
m.choosingBetAmount = false
m.message = ""
return m, m.maybeScheduleBot()
default:
return m, nil
}
if m.betAmount < minTotal {
m.betAmount = minTotal
}
if m.betAmount > maxTotal {
m.betAmount = maxTotal
}
return m, nil
}
func pokerCardStr(c Card) string {
return c.String()
}
// renderPokerBoardRow рисует общие карты стола: уже открытые —
// обычной рамкой, ещё не сданные (до 5 позиций) — рубашкой вниз, с
// той же шириной, чтобы ряд не "прыгал" по мере вскрытия карт.
func renderPokerBoardRow(board []Card) string {
boxes := make([][]string, 5)
for i := 0; i < 5; i++ {
if i < len(board) {
boxes[i] = renderCardBox(board[i], CardNormal)
} else {
boxes[i] = renderFaceDownBox()
}
}
lines := make([]string, 4)
for row := 0; row < 4; row++ {
parts := make([]string, 5)
for i := 0; i < 5; i++ {
parts[i] = boxes[i][row]
}
lines[row] = strings.Join(parts, " ")
}
return strings.Join(lines, "\n")
}
func (m PokerModel) View() string {
if m.quitting {
return T("common.goodbye")
}
var b strings.Builder
fmt.Fprintln(&b, titleStyle.Render(T("poker.title")))
fmt.Fprintln(&b)
g := m.game
if g.Phase == PokerPhaseTournamentOver {
fmt.Fprintln(&b, m.renderTournamentOver())
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("poker.hint.quit")))
return b.String()
}
var board []Card
board = append(board, g.Board...)
fmt.Fprintln(&b, T("poker.board_label"))
fmt.Fprintln(&b, renderPokerBoardRow(board))
fmt.Fprintln(&b, Tf("poker.pot_line", g.potSize()))
fmt.Fprintln(&b)
for i, p := range g.Players {
fmt.Fprintln(&b, m.renderPlayerLine(i, p))
}
fmt.Fprintln(&b)
human := g.Players[m.humanSeat]
if !human.Eliminated {
fmt.Fprintln(&b, T("poker.your_hand_label"))
fmt.Fprintln(&b, renderCardBoxRow(human.HoleCards[:], nil))
}
fmt.Fprintln(&b)
switch {
case g.Phase == PokerPhaseShowdown:
fmt.Fprintln(&b, m.renderShowdown())
fmt.Fprintln(&b)
fmt.Fprintln(&b, T("poker.hint.next_hand"))
case m.choosingBetAmount:
fmt.Fprintln(&b, Tf("poker.bet_amount_line", m.betAmount))
fmt.Fprintln(&b, T("poker.hint.bet_amount"))
case g.CurrentPlayerIdx == m.humanSeat:
fmt.Fprintln(&b, T("poker.hint.your_turn"))
for i, a := range m.pokerHumanActions() {
fmt.Fprintf(&b, "[%d] %s\n", i+1, T("poker.action."+a))
}
default:
fmt.Fprintln(&b, T("common.thinking_generic"))
}
if m.message != "" {
if m.isError {
fmt.Fprintln(&b, errorStyle.Render("! "+m.message))
} else {
fmt.Fprintln(&b, infoStyle.Render(m.message))
}
}
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("poker.hint.quit")))
return b.String()
}
func (m PokerModel) renderPlayerLine(idx int, p *PokerPlayer) string {
name := Tf("poker.seat_label", idx+1)
if idx == m.humanSeat {
name = T("poker.you_label")
}
status := ""
switch {
case p.Eliminated:
status = T("poker.status.eliminated")
case p.Folded:
status = T("poker.status.folded")
case p.AllIn:
status = T("poker.status.allin")
}
marker := ""
if idx == m.game.DealerIdx {
marker = " (D)"
}
line := Tf("poker.player_line", name+marker, p.Stack, p.CurrentBet, status)
if idx == m.game.CurrentPlayerIdx && m.game.Phase != PokerPhaseShowdown && m.game.Phase != PokerPhaseTournamentOver {
return turnStyle.Render(line)
}
return line
}
func (m PokerModel) renderShowdown() string {
var b strings.Builder
res := m.game.Result
if res.WentToShowdown {
fmt.Fprintln(&b, T("poker.showdown.title"))
for idx, v := range res.HandValues {
name := Tf("poker.seat_label", idx+1)
if idx == m.humanSeat {
name = T("poker.you_label")
}
fmt.Fprintf(&b, " %s: %s %s — %s\n", name,
pokerCardStr(m.game.Players[idx].HoleCards[0]), pokerCardStr(m.game.Players[idx].HoleCards[1]),
T(pokerCategoryNameKey[v.Category]))
}
} else {
fmt.Fprintln(&b, T("poker.showdown.fold_title"))
}
for idx, amt := range res.Winners {
if idx == m.humanSeat {
fmt.Fprintln(&b, Tf("poker.showdown.winner_line_you", amt))
} else {
fmt.Fprintln(&b, Tf("poker.showdown.winner_line", Tf("poker.seat_label", idx+1), amt))
}
}
return strings.TrimRight(b.String(), "\n")
}
func (m PokerModel) renderTournamentOver() string {
for i, p := range m.game.Players {
if !p.Eliminated {
name := Tf("poker.seat_label", i+1)
if i == m.humanSeat {
return winStyle.Render(T("poker.outcome.win"))
}
return errorStyle.Render(Tf("poker.outcome.lose", name))
}
}
return ""
}
// pokerCategoryNameKey — ключи переводов названий покерных
// комбинаций для показа на шоудауне.
var pokerCategoryNameKey = map[PokerHandCategory]string{
PokerHighCard: "poker.hand.highcard",
PokerPair: "poker.hand.pair",
PokerTwoPair: "poker.hand.twopair",
PokerThreeOfAKind: "poker.hand.trips",
PokerStraight: "poker.hand.straight",
PokerFlush: "poker.hand.flush",
PokerFullHouse: "poker.hand.fullhouse",
PokerFourOfAKind: "poker.hand.quads",
PokerStraightFlush: "poker.hand.straightflush",
}

215
poker_tui_test.go Normal file
View file

@ -0,0 +1,215 @@
package main
import (
"fmt"
"testing"
)
func TestPokerModelBotsGetRandomDifficulties(t *testing.T) {
m := NewPokerModel(4)
if len(m.bots) != 3 {
t.Fatalf("ожидалось 3 бота (4 игрока минус человек), получено %d", len(m.bots))
}
for seat, bot := range m.bots {
if bot.Difficulty < PokerDifficultyEasy || bot.Difficulty > PokerDifficultyHard {
t.Errorf("бот на месте %d получил недопустимый уровень сложности %v", seat, bot.Difficulty)
}
}
}
func TestPokerModelHumanIsSeatZero(t *testing.T) {
m := NewPokerModel(3)
if m.humanSeat != 0 {
t.Errorf("человек должен всегда занимать место 0, получено %d", m.humanSeat)
}
if _, isBot := m.bots[m.humanSeat]; isBot {
t.Errorf("на месте человека не должно быть бота")
}
}
func TestPokerModelFoldViaKey(t *testing.T) {
m := NewPokerModel(3)
m.game.CurrentPlayerIdx = m.humanSeat
next, _ := m.Update(key("1"))
m = next.(PokerModel)
if !m.game.Players[m.humanSeat].Folded {
t.Errorf("после выбора действия 'сбросить' игрок должен был сбросить карты")
}
}
func TestPokerModelRejectsActionWhenNotHumanTurn(t *testing.T) {
m := NewPokerModel(3)
m.game.CurrentPlayerIdx = (m.humanSeat + 1) % 3
next, _ := m.Update(key("1"))
m = next.(PokerModel)
if m.game.Players[m.humanSeat].Folded {
t.Errorf("действие не должно было приняться — сейчас не ход человека")
}
}
func TestPokerModelBetAmountSubmode(t *testing.T) {
m := NewPokerModel(2)
m.game.CurrentPlayerIdx = m.humanSeat
actions := m.pokerHumanActions()
betIdx := -1
for i, a := range actions {
if a == "bet" {
betIdx = i
}
}
if betIdx < 0 {
t.Fatalf("ожидалось действие 'bet' среди доступных: %v", actions)
}
next, _ := m.Update(key(fmt.Sprintf("%d", betIdx+1)))
m = next.(PokerModel)
if !m.choosingBetAmount {
t.Fatalf("ожидался переход в подрежим выбора суммы ставки")
}
before := m.betAmount
next, _ = m.Update(key("up"))
m = next.(PokerModel)
if m.betAmount != before+m.game.BigBlind {
t.Errorf("ожидалось увеличение ставки на большой блайнд, было %d, стало %d", before, m.betAmount)
}
next, _ = m.Update(key("enter"))
m = next.(PokerModel)
if m.choosingBetAmount {
t.Errorf("подрежим должен был закрыться после подтверждения")
}
human := m.game.Players[m.humanSeat]
if human.CurrentBet != before+m.game.BigBlind {
t.Errorf("ставка должна была реально примениться, ожидалось %d, получено %d", before+m.game.BigBlind, human.CurrentBet)
}
}
func TestPokerModelBetAmountCancelDoesNotApply(t *testing.T) {
m := NewPokerModel(2)
m.game.CurrentPlayerIdx = m.humanSeat
betBefore := m.game.Players[m.humanSeat].CurrentBet
actions := m.pokerHumanActions()
betIdx := 0
for i, a := range actions {
if a == "bet" {
betIdx = i
}
}
next, _ := m.Update(key(fmt.Sprintf("%d", betIdx+1)))
m = next.(PokerModel)
next, _ = m.Update(key("esc"))
m = next.(PokerModel)
if m.choosingBetAmount {
t.Errorf("esc должен был закрыть подрежим без применения ставки")
}
if m.game.Players[m.humanSeat].CurrentBet != betBefore {
t.Errorf("отменённая ставка не должна была примениться")
}
}
func TestPokerModelBotPlaysWhenNotHumanTurn(t *testing.T) {
m := NewPokerModel(3)
for m.game.CurrentPlayerIdx == m.humanSeat {
m.game.CurrentPlayerIdx = (m.game.CurrentPlayerIdx + 1) % 3
}
next, _ := m.Update(botMoveMsg{})
m = next.(PokerModel)
if m.isError {
t.Errorf("неожиданная ошибка хода бота: %s", m.message)
}
}
func TestPokerModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
m := NewPokerModel(3)
m.game.CurrentPlayerIdx = m.humanSeat
before := *m.game
next, _ := m.Update(botMoveMsg{})
m = next.(PokerModel)
if m.game.CurrentPlayerIdx != before.CurrentPlayerIdx {
t.Errorf("botMoveMsg не должно было ничего менять, пока сейчас ход человека")
}
}
func TestPokerModelQReturnsToMenuNotQuitsApp(t *testing.T) {
m := NewPokerModel(3)
next, _ := m.Update(key("q"))
m = next.(PokerModel)
if !m.backToMenu {
t.Errorf("ожидался флаг backToMenu после 'q'")
}
if m.quitting {
t.Errorf("'q' не должно завершать приложение целиком")
}
}
func TestPokerModelNextHandViaKey(t *testing.T) {
m := NewPokerModel(2)
m.game.Players[0].HoleCards = [2]Card{c(Ace, Spades), c(Ace, Hearts)}
m.game.Players[1].HoleCards = [2]Card{c(Two, Clubs), c(Seven, Diamonds)}
m.game.Board = []Card{c(Ace, Clubs), c(King, Spades), c(Queen, Hearts), c(Four, Diamonds), c(Nine, Clubs)}
m.game.Players[0].TotalContributed = 100
m.game.Players[1].TotalContributed = 100
m.game.resolveShowdown()
next, _ := m.Update(key("n"))
m = next.(PokerModel)
if m.game.Phase == PokerPhaseShowdown {
t.Errorf("после 'n' должна была начаться новая раздача")
}
}
func TestPokerModelFullTournamentAutoPlaythrough(t *testing.T) {
m := NewPokerModel(4)
humanBot := PokerBot{Difficulty: PokerDifficultyMedium}
steps := 0
for m.game.Phase != PokerPhaseTournamentOver && steps < 50000 {
steps++
g := m.game
if g.Phase == PokerPhaseShowdown {
next, _ := m.Update(key("n"))
m = next.(PokerModel)
continue
}
if g.CurrentPlayerIdx != m.humanSeat {
next, _ := m.Update(botMoveMsg{})
m = next.(PokerModel)
continue
}
action := humanBot.DecideAction(g, m.humanSeat, m.rng)
actions := m.pokerHumanActions()
labelToKey := map[string]int{}
for i, a := range actions {
labelToKey[a] = i + 1
}
var keyStr string
switch action.Kind {
case "fold":
keyStr = fmt.Sprintf("%d", labelToKey["fold"])
case "check":
if idx, ok := labelToKey["check"]; ok {
keyStr = fmt.Sprintf("%d", idx)
} else {
keyStr = fmt.Sprintf("%d", labelToKey["call"])
}
case "call":
keyStr = fmt.Sprintf("%d", labelToKey["call"])
case "bet":
keyStr = fmt.Sprintf("%d", labelToKey["bet"])
}
next, _ := m.Update(key(keyStr))
m = next.(PokerModel)
if m.choosingBetAmount {
next, _ = m.Update(key("enter"))
m = next.(PokerModel)
}
if m.isError {
t.Fatalf("неожиданная ошибка при автопрогоне: %s", m.message)
}
}
if m.game.Phase != PokerPhaseTournamentOver {
t.Fatalf("турнир не завершился за %d шагов", steps)
}
}

129
rules.go
View file

@ -2425,3 +2425,132 @@ func senetRules() string {
}
return senetRulesRU
}
// --- Техасский Холдем (Texas Hold'em) --------------------------------
const pokerRulesRU = `ТЕХАССКИЙ ХОЛДЕМ (NO-LIMIT) правила
Турнирный формат с выбыванием: от 2 до 6 игроков (вы + боты),
играете, пока не останется один игрок с фишками он и победитель
турнира. Блайнды фиксированы и не растут по ходу турнира (сознательное
упрощение ради управляемого объёма партии).
КОМБИНАЦИИ (от старшей к младшей)
Стрит-флеш, каре, фулл-хаус, флеш, стрит, тройка, две пары, пара,
старшая карта. Из своих 2 карт в руке и 5 общих карт на столе
собирается лучшая комбинация из любых пяти.
ХОД РАЗДАЧИ
Каждому раздаётся по 2 карты в закрытую. Малый и большой блайнды
ставятся автоматически (место дилера смещается на одного игрока
каждую раздачу). Дальше четыре раунда торгов: префлоп (до общих
карт), флоп (три общие карты), тёрн (четвёртая), ривер (пятая), за
которыми следует вскрытие карт, если остался более чем один
игрок. В каждом раунде можно сбросить карты, чекнуть (если ставить
ещё не пришлось), уравнять чужую ставку, поставить/повысить, или
пойти ва-банк оставшимся стеком. Если фишек не хватает на полное
уравнивание идёте в олл-ин тем, что есть, и в игре образуется
отдельный побочный банк для тех, кто может ставить дальше.
ЧТО СОЗНАТЕЛЬНО УПРОЩЕНО
По строгим турнирным правилам короткий олл-ин рейзом МЕНЬШЕ
полного размера предыдущего рейза не должен заново открывать
торги игрокам, которые уже походили в этом раунде они обязаны
лишь доколлировать либо сбросить. Эта игра такого разграничения
не делает: любой рейз (в том числе короткий олл-ин) даёт всем
активным игрокам право хода заново, включая повторный рейз.
Расхождение с турнирными правилами возможно только в редких
ситуациях с несколькими короткостёчными олл-инами подряд.
БОТЫ
Честного решателя GTO (оптимальной по теории игр стратегии) тут
нет вместо этого каждый бот оценивает силу своей руки методом
Монте-Карло (симулирует множество случайных завершений раздачи
против случайных карт соперников) и сравнивает её с шансами
банка. Три уровня сложности лёгкий, средний, сильный отличаются
тем, насколько точно бот считает эту вероятность и насколько
строго учитывает шансы банка; сильный уровень также изредка
блефует. Какой уровень достанется каждому боту решается
случайно при создании партии, выбрать его нельзя.
УПРАВЛЕНИЕ
На своём ходу пронумерованный список из доступных сейчас
действий (сброс, чек или уравнивание, ставка/рейз, ва-банк).
При выборе ставки/рейза:
/hl крупный шаг (5 больших блайндов)
/kj мелкий шаг (1 большой блайнд)
enter подтвердить
esc отменить, вернуться к списку действий
После завершения раздачи:
n/enter следующая раздача
В любой момент:
q выход в меню
`
const pokerRulesEN = `TEXAS HOLD'EM (NO-LIMIT) rules
An elimination tournament format: 2 to 6 players (you plus bots),
playing until only one player has chips left that player wins the
tournament. Blinds are fixed and don't increase over the course of
the tournament (a deliberate simplification to keep the session a
manageable length).
HAND RANKINGS (highest to lowest)
Straight flush, four of a kind, full house, flush, straight, three
of a kind, two pair, pair, high card. The best five-card hand is
built from any combination of your 2 hole cards and the 5 community
cards.
HOW A HAND PLAYS OUT
Everyone is dealt 2 cards face down. The small and big blinds are
posted automatically (the dealer position shifts by one player each
hand). Then come four betting rounds: preflop (before any community
cards), the flop (three community cards), the turn (a fourth), and
the river (a fifth), followed by a showdown if more than one player
remains. On each round you can fold, check (if no bet is
outstanding), call an opponent's bet, bet/raise, or go all-in with
your remaining stack. If you don't have enough to call in full, you
go all-in for what you have, and a separate side pot forms for
players who can still bet further.
WHAT'S DELIBERATELY SIMPLIFIED
Under strict tournament rules, a short all-in raise for LESS than
the full size of the previous raise shouldn't reopen betting for
players who have already acted this round they should only be
required to call the extra amount or fold, not raise again. This
game doesn't draw that distinction: any raise (including a short
all-in) gives every active player the right to act again, including
raising further. The difference from tournament rules only matters
in rare situations with several short all-ins in a row.
THE BOTS
There's no honest GTO (game-theory-optimal) solver here instead,
each bot estimates its own hand strength via Monte Carlo simulation
(simulating many random completions of the hand against random
opponent cards) and compares that to the pot odds. The three
difficulty levels easy, medium, strong differ in how precisely
the bot estimates that probability and how strictly it weighs pot
odds; the strong level also bluffs occasionally. Which level each
bot gets is decided randomly when the game is created you can't
choose it.
CONTROLS
On your turn a numbered list of the actions currently available
(fold, check or call, bet/raise, all-in).
While choosing a bet/raise amount:
/hl big step (5 big blinds)
/kj small step (1 big blind)
enter confirm
esc cancel, back to the action list
After a hand ends:
n/enter next hand
At any time:
q quit to menu
`
func pokerRules() string {
if CurrentLang() == LangEN {
return pokerRulesEN
}
return pokerRulesRU
}

View file

@ -25,8 +25,9 @@ type SenetModel struct {
// NewSenetModel начинает новую партию; кто ходит первым — решается
// случайным броском.
func NewSenetModel(difficulty SenetDifficulty) SenetModel {
func NewSenetModel(difficulty SenetDifficulty, colorChoice ColorChoice) SenetModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
humanColor := resolveSenetColor(colorChoice, rng)
first := SenetPlayerA
if rng.Intn(2) == 1 {
first = SenetPlayerB
@ -35,7 +36,23 @@ func NewSenetModel(difficulty SenetDifficulty) SenetModel {
game: NewSenetGame(first),
bot: SenetBot{Difficulty: difficulty},
rng: rng,
humanColor: SenetPlayerA,
humanColor: humanColor,
}
}
// resolveSenetColor переводит выбор цвета из настроек в конкретного
// SenetPlayer, используя уже созданный генератор модели.
func resolveSenetColor(choice ColorChoice, rng *rand.Rand) SenetPlayer {
switch choice {
case ColorChoiceA:
return SenetPlayerA
case ColorChoiceB:
return SenetPlayerB
default:
if rng.Intn(2) == 0 {
return SenetPlayerA
}
return SenetPlayerB
}
}

View file

@ -6,7 +6,7 @@ import (
)
func TestSenetModelRollViaKey(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
@ -18,7 +18,7 @@ func TestSenetModelRollViaKey(t *testing.T) {
}
func TestSenetModelApplyMoveViaDigitKey(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
m.game.Roll(2)
@ -38,7 +38,7 @@ func TestSenetModelApplyMoveViaDigitKey(t *testing.T) {
}
func TestSenetModelRejectsMoveKeyBeforeRoll(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
@ -50,7 +50,7 @@ func TestSenetModelRejectsMoveKeyBeforeRoll(t *testing.T) {
}
func TestSenetModelBotPlaysWhenNotHumanTurn(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerB)
m.humanColor = SenetPlayerA
@ -62,7 +62,7 @@ func TestSenetModelBotPlaysWhenNotHumanTurn(t *testing.T) {
}
func TestSenetModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
@ -74,7 +74,7 @@ func TestSenetModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
}
func TestSenetModelQReturnsToMenuNotQuitsApp(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(SenetModel)
if !m.backToMenu {
@ -86,7 +86,7 @@ func TestSenetModelQReturnsToMenuNotQuitsApp(t *testing.T) {
}
func TestSenetModelActionsIgnoredAfterGameOver(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy)
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
m.game.Result = &SenetResult{Winner: SenetPlayerA}
@ -99,7 +99,7 @@ func TestSenetModelActionsIgnoredAfterGameOver(t *testing.T) {
}
func TestSenetModelFullAutoPlaythrough(t *testing.T) {
m := NewSenetModel(SenetDifficultyMedium)
m := NewSenetModel(SenetDifficultyMedium, ColorChoiceA)
m.game = NewSenetGame(SenetPlayerA)
m.humanColor = SenetPlayerA
humanBot := SenetBot{Difficulty: SenetDifficultyMedium}
@ -145,3 +145,32 @@ func TestSenetModelFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия не завершилась за %d шагов", steps)
}
}
func TestNewSenetModelRespectsColorChoiceA(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceA)
if m.humanColor != SenetPlayerA {
t.Errorf("при выборе ColorChoiceA человек должен был играть за SenetPlayerA, получено %v", m.humanColor)
}
}
func TestNewSenetModelRespectsColorChoiceB(t *testing.T) {
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceB)
if m.humanColor != SenetPlayerB {
t.Errorf("при выборе ColorChoiceB человек должен был играть за SenetPlayerB, получено %v", m.humanColor)
}
}
func TestNewSenetModelRandomColorGivesValidColor(t *testing.T) {
seenA, seenB := false, false
for i := 0; i < 50 && !(seenA && seenB); i++ {
m := NewSenetModel(SenetDifficultyEasy, ColorChoiceRandom)
if m.humanColor == SenetPlayerA {
seenA = true
} else {
seenB = true
}
}
if !seenA || !seenB {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: A=%v B=%v", seenA, seenB)
}
}

View file

@ -263,15 +263,7 @@ func (m ThousandModel) renderHand() string {
if len(hand) == 0 {
return dimStyle.Render(T("common.no_cards"))
}
parts := make([]string, len(hand))
for i, c := range hand {
text := renderCard(c)
if i == m.cursor {
text = cursorStyle.Render(c.String())
}
parts[i] = text
}
return strings.Join(parts, " ")
return renderCardBoxHand(hand, cursorHighlights(len(hand), m.cursor))
}
func (m ThousandModel) renderPlayers() string {

BIN
tonk

Binary file not shown.

View file

@ -69,7 +69,12 @@ var translations = map[string]translationEntry{
// --- Уровень сложности (Шашки) ---
"checkerslvl.title": {"УРОВЕНЬ СЛОЖНОСТИ", "DIFFICULTY LEVEL"},
"checkerslvl.field_label": {"Сложность бота", "Bot difficulty"},
"checkerslvl.hint": {"[↑↓/←→] выбор уровня [enter] начать игру [esc/q] назад", "[↑↓/←→] choose level [enter] start game [esc/q] back"},
"color.white": {"Белые", "White"},
"color.black": {"Чёрные", "Black"},
"color.random": {"Случайно", "Random"},
"color.field_label": {"Ваш цвет", "Your color"},
"checkerslvl.easy": {"Простой", "Easy"},
"checkerslvl.easy_desc": {"быстрый, ощутимо слабее", "fast, noticeably weaker"},
"checkerslvl.hard": {"Сильный", "Strong"},
@ -140,6 +145,9 @@ var translations = map[string]translationEntry{
"партия уже завершена": {"партия уже завершена", "the match is already over"},
"первый ходящий обязан назвать ставку не менее 100 и не может пасовать": {"первый ходящий обязан назвать ставку не менее 100 и не может пасовать", "the first bidder must bid at least 100 and cannot pass"},
"сейчас не ваш ход": {"сейчас не ваш ход", "it isn't your turn right now"},
"нельзя пасовать (чек) — на столе уже есть ставка, которую нужно уравнять или сбросить": {"нельзя пасовать (чек) — на столе уже есть ставка, которую нужно уравнять или сбросить", "you can't check — there's already a bet on the table that must be called or folded"},
"ставка меньше минимально допустимой": {"ставка меньше минимально допустимой", "the bet is below the minimum allowed"},
"недостаточно фишек для такой ставки": {"недостаточно фишек для такой ставки", "not enough chips for that bet"},
"ставка должна быть выше текущей минимум на 5": {"ставка должна быть выше текущей минимум на 5", "the bid must be at least 5 higher than the current one"},
"играть втёмную можно только на самом первом ходе торгов": {"играть втёмную можно только на самом первом ходе торгов", "playing blind is only possible on the very first bidding turn"},
"ставка превышает разрешённый максимум (120 плюс марьяж на руке)": {"ставка превышает разрешённый максимум (120 плюс марьяж на руке)", "the bid exceeds the allowed maximum (120 plus any marriage in hand)"},
@ -214,6 +222,42 @@ var translations = map[string]translationEntry{
"senet.msg.no_legal_moves": {"У вас не нашлось ни одного хода.", "You had no legal moves."},
"senet.msg.game_over": {"Партия завершена.", "The game is over."},
"poker.title": {"=== ТЕХАССКИЙ ХОЛДЕМ ===", "=== TEXAS HOLD'EM ==="},
"poker.board_label": {"Стол:", "Board:"},
"poker.pot_line": {"Банк: %d", "Pot: %d"},
"poker.your_hand_label": {"Ваши карты:", "Your hand:"},
"poker.seat_label": {"Игрок %d", "Player %d"},
"poker.you_label": {"Вы", "You"},
"poker.player_line": {"%-12s фишки: %-6d ставка: %-6d %s", "%-12s chips: %-6d bet: %-6d %s"},
"poker.status.folded": {"[сбросил]", "[folded]"},
"poker.status.allin": {"[олл-ин]", "[all-in]"},
"poker.status.eliminated": {"[выбыл]", "[eliminated]"},
"poker.hint.your_turn": {"Ваш ход:", "Your turn:"},
"poker.hint.next_hand": {"[n/enter] следующая раздача", "[n/enter] next hand"},
"poker.hint.bet_amount": {"[←→/hl] крупный шаг [↑↓/kj] мелкий шаг [enter] подтвердить [esc] отмена", "[←→/hl] big step [↑↓/kj] small step [enter] confirm [esc] cancel"},
"poker.hint.quit": {"[q] в меню", "[q] to menu"},
"poker.bet_amount_line": {"Ставка/рейз: %d", "Bet/raise: %d"},
"poker.action.fold": {"Сбросить карты", "Fold"},
"poker.action.check": {"Чек", "Check"},
"poker.action.call": {"Уравнять", "Call"},
"poker.action.bet": {"Поставить/рейз", "Bet/raise"},
"poker.action.allin": {"Ва-банк (олл-ин)", "All-in"},
"poker.showdown.title": {"Вскрытие карт:", "Showdown:"},
"poker.showdown.fold_title": {"Все соперники сбросили карты.", "All opponents folded."},
"poker.showdown.winner_line": {" %s забирает: %d", " %s wins: %d"},
"poker.showdown.winner_line_you": {" Вы забираете: %d", " You win: %d"},
"poker.outcome.win": {"Вы выиграли турнир!", "You won the tournament!"},
"poker.outcome.lose": {"Турнир выиграл: %s", "The tournament was won by: %s"},
"poker.hand.highcard": {"старшая карта", "high card"},
"poker.hand.pair": {"пара", "pair"},
"poker.hand.twopair": {"две пары", "two pair"},
"poker.hand.trips": {"тройка", "three of a kind"},
"poker.hand.straight": {"стрит", "straight"},
"poker.hand.flush": {"флеш", "flush"},
"poker.hand.fullhouse": {"фулл-хаус", "full house"},
"poker.hand.quads": {"каре", "four of a kind"},
"poker.hand.straightflush": {"стрит-флеш", "straight flush"},
"tictactoe.title": {"=== КРЕСТИКИ-НОЛИКИ ===", "=== TIC-TAC-TOE ==="},
"tictactoe.your_mark": {"Вы играете за: %s", "You are playing as: %s"},
"tictactoe.hint.turn": {"Ваш ход.", "Your turn."},
@ -290,7 +334,7 @@ var translations = map[string]translationEntry{
func init() {
for k, v := range map[string]translationEntry{
"blackjack.title": {"=== БЛЭКДЖЕК ===", "=== BLACKJACK ==="},
"blackjack.dealer_line": {"Дилер: %s\n\n", "Dealer: %s\n\n"},
"blackjack.dealer_line": {"Дилер:\n%s\n\n", "Dealer:\n%s\n\n"},
"blackjack.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"},
"blackjack.hint.turn": {"[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню", "[h] hit [s] stand [d] double down [q] menu"},
"blackjack.msg.hit": {"Вы взяли карту.", "You took a card."},
@ -300,8 +344,10 @@ func init() {
"blackjack.status.busted": {"перебор", "bust"},
"blackjack.status.blackjack": {"блэкджек!", "blackjack!"},
"blackjack.status.playing": {"ходит", "playing"},
"blackjack.player_line": {"%s%-20s ставка: %-4d карты: %s (%d) [%s]", "%s%-20s bet: %-4d cards: %s (%d) [%s]"},
"blackjack.dealer_result": {"Дилер: %s (%d)", "Dealer: %s (%d)"},
"blackjack.player_header": {"%s%-20s ставка: %-4d итог: %d [%s]", "%s%-20s bet: %-4d total: %d [%s]"},
"blackjack.msg.bet_reduced": {"Не хватает на полную ставку — играете на всё, что осталось: %d", "Not enough for the full bet — playing with everything you have left: %d"},
"blackjack.msg.no_funds": {"Денег не осталось — этот раунд играется без ставки.", "You're out of chips — this round is played with no stake."},
"blackjack.hand_with_total": {"%s (всего: %d)", "%s (total: %d)"},
"blackjack.busted_suffix": {" — перебор!", " — bust!"},
"blackjack.outcome.blackjack": {"блэкджек! выигрыш 3:2", "blackjack! 3:2 win"},
"blackjack.outcome.win": {"выигрыш", "win"},
@ -312,6 +358,8 @@ func init() {
"oneohone.title": {"=== 101 ===", "=== 101 ==="},
"oneohone.status_line": {"Сброс сверху: %s В колоде: %d карт\n\n", "Top discard: %s Cards left: %d\n\n"},
"oneohone.pending_eight_line": {"Нужно покрыть восьмёрку: масть %s или другая восьмёрка", "You must cover the eight: suit %s or another eight"},
"oneohone.declared_suit_line": {"Дама назвала масть: %s (действует один ход)", "Queen declared suit: %s (lasts one move)"},
"oneohone.hint.gameover_match": {"[q] в меню", "[q] menu"},
"oneohone.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"},
"oneohone.hint.turn": {"[←→/hl] выбор карты [enter] сыграть карту [d] взять из колоды (если нечем ходить) [q] в меню", "[←→/hl] choose card [enter] play card [d] draw from stock (if you can't play) [q] menu"},
@ -369,8 +417,9 @@ func init() {
"klondike.msg.moved": {"Ход выполнен.", "Move made."},
"klondike.msg.to_foundation": {"Карта отправлена на фундамент.", "Card sent to the foundation."},
"klondike.empty": {"(пусто)", "(empty)"},
"klondike.status_line": {"Колода: %s Отбой: %s", "Stock: %s Waste: %s"},
"klondike.foundations_line": {"Фундаменты: %s\n", "Foundations: %s\n"},
"klondike.stock_line": {"Колода: (%d)", "Stock: (%d)"},
"klondike.waste_label": {"Отбой:", "Waste:"},
"klondike.foundations_line": {"Фундаменты:\n%s\n", "Foundations:\n%s\n"},
"klondike.moves_line": {"Ходов: %d\n\n", "Moves: %d\n\n"},
"klondike.won": {"Пасьянс сошёлся за %d ходов! Поздравляем!", "Solved in %d moves! Congratulations!"},
"klondike.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"},
@ -424,6 +473,8 @@ func init() {
"game.ur.desc": {"Одна из древнейших настольных игр (Месопотамия, ~2600 до н.э.) — реконструкция правил Ирвинга Финкеля, три уровня сложности бота", "One of the oldest known board games (Mesopotamia, ~2600 BCE) — Irving Finkel's rule reconstruction, three bot difficulty levels"},
"game.senet.name": {"Сенет (Senet)", "Senet"},
"game.senet.desc": {"Древнеегипетская игра (известна с ~3500 до н.э.) — консолидированная реконструкция правил, три уровня сложности бота", "An ancient Egyptian game (attested since ~3500 BCE) — a consolidated rule reconstruction, three bot difficulty levels"},
"game.poker.name": {"Техасский Холдем", "Texas Hold'em"},
"game.poker.desc": {"Полноценный турнир No-Limit с выбыванием, от 2 до 6 игроков; уровень каждого бота (лёгкий/средний/сильный) назначается случайно", "A full No-Limit elimination tournament, 2 to 6 players; each bot's difficulty (easy/medium/strong) is assigned randomly"},
"corpsestarch.title": {"=== ТРУПНЫЕ БАТОНЧИКИ ===", "=== CORPSE-STARCH BOX ==="},
"corpsestarch.header.starch": {"Трупный крахмал: %s (всего добыто: %s)", "Corpse starch: %s (total gathered: %s)"},

205
tui.go
View file

@ -14,6 +14,45 @@ import (
// небольшой задержкой, а не проваливались мгновенно.
type botMoveMsg struct{}
// ColorChoice — выбор стороны в играх с двумя цветами: конкретный
// цвет (первый или второй в списке для этой игры) либо случайный
// выбор при старте партии. Общий тип для настроек Шашек, Шахмат,
// Го, Нард, Ура и Сенета — какая КОНКРЕТНО пара цветов ему
// соответствует ("белые/чёрные" и т.п.), решает уже сама игра.
type ColorChoice int
const (
ColorChoiceA ColorChoice = iota
ColorChoiceB
ColorChoiceRandom
)
// colorChoiceLabel возвращает переведённую подпись для текущего
// выбора цвета; labelA/labelB — ключи переводов конкретных цветов
// этой игры (порядок зависит от игры — например, в Го традиционно
// первый ход за чёрными, поэтому там ColorChoiceA — "Чёрные").
func colorChoiceLabel(choice ColorChoice, labelAKey, labelBKey string) string {
switch choice {
case ColorChoiceA:
return T(labelAKey)
case ColorChoiceB:
return T(labelBKey)
default:
return T("color.random")
}
}
func (c *ColorChoice) cycle(delta int) {
v := int(*c) + delta
if v < 0 {
v = 0
}
if v > int(ColorChoiceRandom) {
v = int(ColorChoiceRandom)
}
*c = ColorChoice(v)
}
const botMoveDelay = 500 * time.Millisecond
// wrapText переносит длинный текст по ширине терминала — с разумным
@ -404,32 +443,168 @@ func renderCard(c Card) string {
return cardColorStyle(c).Render(c.String())
}
// CardHighlight — состояние подсветки одной карты в ряду.
type CardHighlight int
const (
CardNormal CardHighlight = iota
CardCursor // текущая позиция курсора
CardStaged // отложена под мелд (Тонк) — подчёркнута, цвет масти сохраняется
)
// cardBoxContentWidth — внутренняя ширина рамочной карты (вариант
// "B": ранг в левом верхнем углу, масть в правом нижнем). Подобрана
// как компромисс между похожестью на настоящую карту и тем, чтобы
// рука из нескольких карт не выходила за пределы узкого мобильного
// терминала.
const cardBoxContentWidth = 3
// cardsPerBoxRow — после скольких карт подряд рамочный ряд руки
// переносится на новую строку (см. renderCardBoxHand). Число
// фиксировано и не зависит от реальной ширины терминала —
// сознательное упрощение: 6 карт в ряд при content=3 укладываются в
// разумные ~35 символов на большинстве терминалов, а курсор при
// переносе остаётся линейным индексом — перенос влияет только на
// раскладку, не на навигацию.
const cardsPerBoxRow = 6
// renderCardBox рисует одну карту рамкой в 4 строки: верхняя рамка,
// ранг (выровнен по левому краю), масть (выровнена по правому
// краю — по диагонали от ранга), нижняя рамка.
//
// Курсор подсвечивается цветом РАМКИ (не текста) — иначе ранг и
// масть было бы не различить поверх инвертированных цветов, как
// раньше в однострочном варианте. "Отложена под мелд" — наоборот,
// подчёркнутый и жирный текст при обычной рамке, без инверсии цвета
// масти (важно для Тонка: там нужно видеть, какая именно карта
// отложена, не теряя красный/чёрный цвет).
func renderCardBox(c Card, highlight CardHighlight) []string {
rank := c.Rank.String()
for len([]rune(rank)) < cardBoxContentWidth {
rank += " "
}
suit := c.Suit.String()
for len([]rune(suit)) < cardBoxContentWidth {
suit = " " + suit
}
var rankText, suitText string
switch highlight {
case CardStaged:
// один стиль на весь текст, а не Render() поверх уже
// отрендеренной строки — вложенные ANSI-последовательности
// ломаются в некоторых терминалах (см. tui.go, renderHand)
st := cardColorStyle(c).Underline(true).Bold(true)
rankText, suitText = st.Render(rank), st.Render(suit)
default:
style := cardColorStyle(c)
rankText, suitText = style.Render(rank), style.Render(suit)
}
top := "┌" + strings.Repeat("─", cardBoxContentWidth) + "┐"
bottom := "└" + strings.Repeat("─", cardBoxContentWidth) + "┘"
if highlight == CardCursor {
top = turnStyle.Render(top)
bottom = turnStyle.Render(bottom)
}
return []string{
top,
"│" + rankText + "│",
"│" + suitText + "│",
bottom,
}
}
// cursorHighlights строит срез подсветки для игр, которым не нужно
// ничего, кроме курсора: все карты обычные, кроме позиции cursor.
func cursorHighlights(n, cursor int) []CardHighlight {
hl := make([]CardHighlight, n)
for i := range hl {
if i == cursor {
hl[i] = CardCursor
}
}
return hl
}
// renderFaceDownBox рисует рамку карты рубашкой вниз (масть и ранг
// неизвестны) — той же ширины, что и обычная renderCardBox, чтобы
// ряды из известных и скрытых карт выравнивались друг с другом.
func renderFaceDownBox() []string {
top := "┌" + strings.Repeat("─", cardBoxContentWidth) + "┐"
bottom := "└" + strings.Repeat("─", cardBoxContentWidth) + "┘"
mid := dimStyle.Render(" ? ")
return []string{top, "│" + mid + "│", "│" + mid + "│", bottom}
}
// renderCardBoxRow рисует один ряд карт рамками (без переноса — см.
// renderCardBoxHand для руки произвольной длины), склеенных по
// горизонтали через один пробел.
func renderCardBoxRow(cards []Card, highlights []CardHighlight) string {
if len(cards) == 0 {
return ""
}
boxes := make([][]string, len(cards))
for i, c := range cards {
h := CardNormal
if i < len(highlights) {
h = highlights[i]
}
boxes[i] = renderCardBox(c, h)
}
lines := make([]string, 4)
for row := 0; row < 4; row++ {
parts := make([]string, len(cards))
for i := range cards {
parts[i] = boxes[i][row]
}
lines[row] = strings.Join(parts, " ")
}
return strings.Join(lines, "\n")
}
// renderCardBoxHand рисует руку карт произвольной длины, перенося
// каждые cardsPerBoxRow карт на новую строку. Курсор/отложенные
// карты остаются привязаны к линейному индексу в cards — перенос
// влияет только на раскладку, не на то, как считается позиция.
func renderCardBoxHand(cards []Card, highlights []CardHighlight) string {
if len(cards) == 0 {
return ""
}
var rows []string
for start := 0; start < len(cards); start += cardsPerBoxRow {
end := start + cardsPerBoxRow
if end > len(cards) {
end = len(cards)
}
rowHighlights := highlights
if end <= len(highlights) {
rowHighlights = highlights[start:end]
} else if start < len(highlights) {
rowHighlights = highlights[start:]
} else {
rowHighlights = nil
}
rows = append(rows, renderCardBoxRow(cards[start:end], rowHighlights))
}
return strings.Join(rows, "\n\n")
}
func (m Model) renderHand() string {
hand := m.game.current().Hand
if len(hand) == 0 {
return dimStyle.Render(T("common.no_cards"))
}
parts := make([]string, len(hand))
highlights := make([]CardHighlight, len(hand))
for i, c := range hand {
var text string
switch {
case i == m.cursor:
// курсор — поверх обычного (нецветного) текста карты,
// как и раньше
text = cursorStyle.Render(c.String())
highlights[i] = CardCursor
case m.staged[c]:
// важно: цвет и подчёркивание в ОДНОМ стиле, а не
// Render() поверх уже отрендеренной (цветной) строки —
// вложенные ANSI-последовательности ломаются в
// некоторых терминалах (вложенный сброс обрывает
// внешний стиль раньше времени)
text = cardColorStyle(c).Underline(true).Bold(true).Render(c.String())
default:
text = renderCard(c)
highlights[i] = CardStaged
}
parts[i] = text
}
return strings.Join(parts, " ")
return renderCardBoxHand(hand, highlights)
}
func (m Model) renderTable() string {

View file

@ -1,9 +1,12 @@
package main
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
// key — маленький хелпер для конструирования tea.KeyMsg, которые
@ -277,3 +280,134 @@ func TestTUIFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия завершилась без результата")
}
}
func TestRenderCardBoxContentShowsRankAndSuitSeparately(t *testing.T) {
box := renderCardBox(c(Ace, Diamonds), CardNormal)
rankLine := stripANSI(box[1])
suitLine := stripANSI(box[2])
if !strings.Contains(rankLine, "A") || strings.Contains(rankLine, "♦") {
t.Errorf("строка ранга должна содержать только ранг, получено %q", rankLine)
}
if !strings.Contains(suitLine, "♦") || strings.Contains(suitLine, "A") {
t.Errorf("строка масти должна содержать только масть, получено %q", suitLine)
}
}
func TestRenderCardBoxHasFourLines(t *testing.T) {
box := renderCardBox(c(Ace, Spades), CardNormal)
if len(box) != 4 {
t.Fatalf("ожидалось 4 строки (верх/ранг/масть/низ), получено %d", len(box))
}
}
func TestRenderCardBoxAlignsTenSameWidthAsSingleDigit(t *testing.T) {
tenBox := renderCardBox(c(Ten, Spades), CardNormal)
aceBox := renderCardBox(c(Ace, Spades), CardNormal)
for row := 0; row < 4; row++ {
wTen := len([]rune(stripANSI(tenBox[row])))
wAce := len([]rune(stripANSI(aceBox[row])))
if wTen != wAce {
t.Errorf("строка %d: карта с рангом '10' (ширина %d) должна была совпадать по ширине с картой ранга 'A' (ширина %d)", row, wTen, wAce)
}
}
}
func TestRenderCardBoxCursorHighlightsBorderNotText(t *testing.T) {
oldProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
normal := renderCardBox(c(Ace, Spades), CardNormal)
cursor := renderCardBox(c(Ace, Spades), CardCursor)
// текст (ранг/масть, строки 1-2) не должен меняться от курсора —
// подсвечивается только рамка (строки 0 и 3)
if stripANSI(normal[1]) != stripANSI(cursor[1]) || stripANSI(normal[2]) != stripANSI(cursor[2]) {
t.Errorf("курсор не должен менять видимый текст ранга/масти, только рамку")
}
if normal[0] == cursor[0] {
t.Errorf("курсор должен был визуально отличать верхнюю рамку (по стилю), получены одинаковые строки")
}
}
func TestRenderCardBoxStagedUnderlinesTextKeepsBorder(t *testing.T) {
oldProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.ANSI256)
defer lipgloss.SetColorProfile(oldProfile)
normal := renderCardBox(c(Ace, Spades), CardNormal)
staged := renderCardBox(c(Ace, Spades), CardStaged)
if stripANSI(normal[0]) != stripANSI(staged[0]) || stripANSI(normal[3]) != stripANSI(staged[3]) {
t.Errorf("отложенная карта не должна менять рамку, только текст")
}
if normal[1] == staged[1] {
t.Errorf("отложенная карта должна была визуально отличаться (подчёркивание) в строке ранга")
}
}
func TestRenderCardBoxRowJoinsMultipleCardsWithEqualWidth(t *testing.T) {
hand := []Card{c(Ace, Spades), c(Ten, Hearts), c(King, Clubs)}
row := renderCardBoxRow(hand, nil)
lines := strings.Split(row, "\n")
if len(lines) != 4 {
t.Fatalf("ожидалось 4 строки в ряду карт, получено %d", len(lines))
}
width0 := len([]rune(stripANSI(lines[0])))
for i, line := range lines {
if w := len([]rune(stripANSI(line))); w != width0 {
t.Errorf("строка %d ряда карт имеет ширину %d, ожидалась %d (все строки должны быть выровнены по прямоугольнику)", i, w, width0)
}
}
}
func TestRenderCardBoxRowEmptyHand(t *testing.T) {
if got := renderCardBoxRow(nil, nil); got != "" {
t.Errorf("для пустой руки ожидалась пустая строка, получено %q", got)
}
}
func TestCursorHighlightsMarksOnlyCursorPosition(t *testing.T) {
hl := cursorHighlights(5, 2)
for i, h := range hl {
want := CardNormal
if i == 2 {
want = CardCursor
}
if h != want {
t.Errorf("позиция %d: ожидалось %v, получено %v", i, want, h)
}
}
}
func TestRenderCardBoxHandWrapsAfterSixCards(t *testing.T) {
hand := make([]Card, 8)
for i := range hand {
hand[i] = c(Rank(2+i%9), Clubs)
}
out := renderCardBoxHand(hand, cursorHighlights(len(hand), 0))
// два ряда по 4 строки, разделённые пустой строкой между ними
lines := strings.Split(out, "\n")
if len(lines) != 9 {
t.Fatalf("ожидалось 9 строк (4+пустая+4) для переноса 8 карт по 6 в ряд, получено %d:\n%s", len(lines), out)
}
if lines[4] != "" {
t.Errorf("между рядами должна быть пустая строка-разделитель, получено %q", lines[4])
}
}
func TestRenderCardBoxHandNoWrapWithinLimit(t *testing.T) {
hand := make([]Card, cardsPerBoxRow)
for i := range hand {
hand[i] = c(Rank(2+i), Clubs)
}
out := renderCardBoxHand(hand, cursorHighlights(len(hand), 0))
lines := strings.Split(out, "\n")
if len(lines) != 4 {
t.Fatalf("ровно %d карт должны были уместиться в один ряд (4 строки), получено %d строк", cardsPerBoxRow, len(lines))
}
}
func TestRenderCardBoxHandEmpty(t *testing.T) {
if got := renderCardBoxHand(nil, nil); got != "" {
t.Errorf("для пустой руки ожидалась пустая строка, получено %q", got)
}
}

View file

@ -25,8 +25,9 @@ type UrModel struct {
// NewUrModel начинает новую партию; кто ходит первым — решается
// случайным броском (как и предписывают правила).
func NewUrModel(difficulty UrDifficulty) UrModel {
func NewUrModel(difficulty UrDifficulty, colorChoice ColorChoice) UrModel {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
humanColor := resolveUrColor(colorChoice, rng)
first := UrPlayerA
if rng.Intn(2) == 1 {
first = UrPlayerB
@ -35,7 +36,23 @@ func NewUrModel(difficulty UrDifficulty) UrModel {
game: NewUrGame(first),
bot: UrBot{Difficulty: difficulty},
rng: rng,
humanColor: UrPlayerA,
humanColor: humanColor,
}
}
// resolveUrColor переводит выбор цвета из настроек в конкретного
// UrPlayer, используя уже созданный генератор модели.
func resolveUrColor(choice ColorChoice, rng *rand.Rand) UrPlayer {
switch choice {
case ColorChoiceA:
return UrPlayerA
case ColorChoiceB:
return UrPlayerB
default:
if rng.Intn(2) == 0 {
return UrPlayerA
}
return UrPlayerB
}
}

View file

@ -6,7 +6,7 @@ import (
)
func TestUrModelRollViaKey(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
@ -18,7 +18,7 @@ func TestUrModelRollViaKey(t *testing.T) {
}
func TestUrModelApplyMoveViaDigitKey(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
m.game.Roll(3)
@ -37,7 +37,7 @@ func TestUrModelApplyMoveViaDigitKey(t *testing.T) {
}
func TestUrModelRejectsMoveKeyBeforeRoll(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
@ -49,7 +49,7 @@ func TestUrModelRejectsMoveKeyBeforeRoll(t *testing.T) {
}
func TestUrModelBotPlaysWhenNotHumanTurn(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerB)
m.humanColor = UrPlayerA
@ -61,7 +61,7 @@ func TestUrModelBotPlaysWhenNotHumanTurn(t *testing.T) {
}
func TestUrModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
@ -73,7 +73,7 @@ func TestUrModelIgnoresBotMoveMsgOnHumanTurn(t *testing.T) {
}
func TestUrModelQReturnsToMenuNotQuitsApp(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
next, _ := m.Update(key("q"))
m = next.(UrModel)
if !m.backToMenu {
@ -85,7 +85,7 @@ func TestUrModelQReturnsToMenuNotQuitsApp(t *testing.T) {
}
func TestUrModelActionsIgnoredAfterGameOver(t *testing.T) {
m := NewUrModel(UrDifficultyEasy)
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
m.game.Result = &UrResult{Winner: UrPlayerA}
@ -98,7 +98,7 @@ func TestUrModelActionsIgnoredAfterGameOver(t *testing.T) {
}
func TestUrModelFullAutoPlaythrough(t *testing.T) {
m := NewUrModel(UrDifficultyMedium)
m := NewUrModel(UrDifficultyMedium, ColorChoiceA)
m.game = NewUrGame(UrPlayerA)
m.humanColor = UrPlayerA
humanBot := UrBot{Difficulty: UrDifficultyMedium}
@ -144,3 +144,32 @@ func TestUrModelFullAutoPlaythrough(t *testing.T) {
t.Fatalf("партия не завершилась за %d шагов", steps)
}
}
func TestNewUrModelRespectsColorChoiceA(t *testing.T) {
m := NewUrModel(UrDifficultyEasy, ColorChoiceA)
if m.humanColor != UrPlayerA {
t.Errorf("при выборе ColorChoiceA человек должен был играть за UrPlayerA, получено %v", m.humanColor)
}
}
func TestNewUrModelRespectsColorChoiceB(t *testing.T) {
m := NewUrModel(UrDifficultyEasy, ColorChoiceB)
if m.humanColor != UrPlayerB {
t.Errorf("при выборе ColorChoiceB человек должен был играть за UrPlayerB, получено %v", m.humanColor)
}
}
func TestNewUrModelRandomColorGivesValidColor(t *testing.T) {
seenA, seenB := false, false
for i := 0; i < 50 && !(seenA && seenB); i++ {
m := NewUrModel(UrDifficultyEasy, ColorChoiceRandom)
if m.humanColor == UrPlayerA {
seenA = true
} else {
seenB = true
}
}
if !seenA || !seenB {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: A=%v B=%v", seenA, seenB)
}
}