Fixes
This commit is contained in:
parent
d6f1d11814
commit
9353ab527a
41 changed files with 3608 additions and 263 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-решателя — оценка силы руки методом Монте-Карло плюс шансы банка, не идеальная игра.
|
||||
|
|
|
|||
|
|
@ -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 карты каждому игроку и дилеру, по одной за проход
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("ожидалось информационное сообщение об отсутствии денег")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
chess_tui.go
25
chess_tui.go
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
durak_tui.go
10
durak_tui.go
|
|
@ -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)
BIN
ggc
(Stored with Git LFS)
Binary file not shown.
BIN
ggc.exe
(Stored with Git LFS)
BIN
ggc.exe
(Stored with Git LFS)
Binary file not shown.
24
go_tui.go
24
go_tui.go
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
216
menu.go
|
|
@ -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 := " "
|
||||
|
|
|
|||
17
menu_test.go
17
menu_test.go
|
|
@ -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)
|
||||
|
|
|
|||
22
nardy_tui.go
22
nardy_tui.go
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
264
poker_bot.go
Normal 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
133
poker_bot_test.go
Normal 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
562
poker_game.go
Normal 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
287
poker_game_test.go
Normal 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
188
poker_hand.go
Normal 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
150
poker_hand_test.go
Normal 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
423
poker_tui.go
Normal 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
215
poker_tui_test.go
Normal 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
129
rules.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
21
senet_tui.go
21
senet_tui.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
BIN
tonk
Binary file not shown.
|
|
@ -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
205
tui.go
|
|
@ -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 {
|
||||
|
|
|
|||
134
tui_test.go
134
tui_test.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
ur_tui.go
21
ur_tui.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue