358 lines
15 KiB
Go
358 lines
15 KiB
Go
package main
|
||
|
||
import "testing"
|
||
|
||
func bc(rank Rank, suit Suit) Card { return Card{Rank: rank, Suit: suit} }
|
||
|
||
// resetForManualBout приводит только что созданную игру в чистое
|
||
// состояние перед ручной подстановкой рук в тесте. Это нужно,
|
||
// потому что NewBlackjackGame раздаёт случайные карты — и если
|
||
// кому-то случайно выпадает натуральный блэкджек, конструктор сам
|
||
// доигрывает и разрешает раунд ещё до того, как тест успевает
|
||
// подставить нужные карты. Без сброса такой "случайный" исход
|
||
// остаётся в g.Result и в Balance, и повторный вызов
|
||
// advanceToNextPlayerOrResolve в тесте задваивает выплату.
|
||
func resetForManualBout(g *BlackjackGameState) {
|
||
g.Result = nil
|
||
g.Phase = BJPhasePlayerTurn
|
||
for i, p := range g.Players {
|
||
p.Status = BJStatusPlaying
|
||
p.Balance = g.preRoundBalance[i] - p.Bet
|
||
}
|
||
}
|
||
|
||
func TestHandTotalBasic(t *testing.T) {
|
||
cases := []struct {
|
||
hand []Card
|
||
want int
|
||
}{
|
||
{[]Card{bc(King, Clubs), bc(Seven, Hearts)}, 17},
|
||
{[]Card{bc(Ace, Clubs), bc(King, Hearts)}, 21},
|
||
{[]Card{bc(Ace, Clubs), bc(Ace, Hearts)}, 12}, // 11+1
|
||
{[]Card{bc(Ace, Clubs), bc(Ace, Hearts), bc(Nine, Diamonds)}, 21}, // 1+1+9+... нет: 11+1+9=21
|
||
{[]Card{bc(King, Clubs), bc(Queen, Hearts), bc(Two, Diamonds)}, 22},
|
||
}
|
||
for i, tc := range cases {
|
||
got := handTotal(tc.hand)
|
||
if got != tc.want {
|
||
t.Errorf("случай %d: ожидалось %d, получено %d (рука %v)", i, tc.want, got, tc.hand)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestIsNaturalBlackjack(t *testing.T) {
|
||
if !isNaturalBlackjack([]Card{bc(Ace, Clubs), bc(King, Hearts)}) {
|
||
t.Errorf("туз+король должен быть натуральным блэкджеком")
|
||
}
|
||
if isNaturalBlackjack([]Card{bc(Seven, Clubs), bc(Seven, Hearts), bc(Seven, Diamonds)}) {
|
||
t.Errorf("21 из трёх карт не должен считаться натуральным блэкджеком")
|
||
}
|
||
if isNaturalBlackjack([]Card{bc(King, Clubs), bc(Queen, Hearts)}) {
|
||
t.Errorf("20 не должен считаться блэкджеком")
|
||
}
|
||
}
|
||
|
||
func TestNewBlackjackGameDealsAndCollectsBet(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
for i, p := range g.Players {
|
||
// ровно 2 карты игроку раздаются всегда, независимо от
|
||
// исхода — даже если у кого-то мгновенно блэкджек и раунд
|
||
// уже разрешился, руки игроков от этого не меняются
|
||
if len(p.Hand) != 2 {
|
||
t.Errorf("игрок %d: ожидалось 2 карты, получено %d", i, len(p.Hand))
|
||
}
|
||
if p.Bet != 10 {
|
||
t.Errorf("игрок %d: ожидалась ставка 10, получено %d", i, p.Bet)
|
||
}
|
||
// капитал сразу после ставки (90) проверяем только пока
|
||
// раунд ещё не разрешился сам — если кому-то выпал
|
||
// натуральный блэкджек, конструктор уже доиграл дилера и
|
||
// начислил выплату, и Balance закономерно отличается от 90
|
||
if p.Status == BJStatusPlaying && g.Phase == BJPhasePlayerTurn {
|
||
if p.Balance != 90 {
|
||
t.Errorf("игрок %d: ожидался капитал 90 после ставки, получено %d", i, p.Balance)
|
||
}
|
||
}
|
||
}
|
||
if len(g.Dealer) < 2 {
|
||
t.Errorf("у дилера должно быть минимум 2 карты, получено %d", len(g.Dealer))
|
||
}
|
||
}
|
||
|
||
func TestHitAddsCardAndBustEndsTurn(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts)} // 20
|
||
g.Players[0].Status = BJStatusPlaying
|
||
g.CurrentPlayerIdx = 0
|
||
g.Phase = BJPhasePlayerTurn
|
||
// подменяем колоду, чтобы гарантированно добрать карту с перебором
|
||
g.Deck = &Deck{cards: []Card{bc(Five, Diamonds)}}
|
||
|
||
if err := g.Hit(); err != nil {
|
||
t.Fatalf("неожиданная ошибка: %v", err)
|
||
}
|
||
if g.Players[0].Status != BJStatusBusted {
|
||
t.Fatalf("ожидался перебор, статус: %v", g.Players[0].Status)
|
||
}
|
||
if g.CurrentPlayerIdx != 1 {
|
||
t.Fatalf("ход должен был перейти к игроку 1, получено %d", g.CurrentPlayerIdx)
|
||
}
|
||
}
|
||
|
||
func TestStandMovesToNextPlayer(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Status = BJStatusPlaying
|
||
g.Players[1].Status = BJStatusPlaying
|
||
g.CurrentPlayerIdx = 0
|
||
g.Phase = BJPhasePlayerTurn
|
||
|
||
if err := g.Stand(); err != nil {
|
||
t.Fatalf("неожиданная ошибка: %v", err)
|
||
}
|
||
if g.Players[0].Status != BJStatusStood {
|
||
t.Fatalf("ожидался статус stood")
|
||
}
|
||
if g.CurrentPlayerIdx != 1 {
|
||
t.Fatalf("ход должен был перейти к игроку 1")
|
||
}
|
||
}
|
||
|
||
func TestDoubleDownDoublesbetAndDrawsOneCard(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)} // 11
|
||
g.Players[0].Status = BJStatusPlaying
|
||
g.Players[0].Balance = 90
|
||
g.Players[0].Bet = 10
|
||
g.CurrentPlayerIdx = 0
|
||
g.Phase = BJPhasePlayerTurn
|
||
g.Deck = &Deck{cards: []Card{bc(King, Diamonds)}}
|
||
|
||
if err := g.DoubleDown(); err != nil {
|
||
t.Fatalf("неожиданная ошибка: %v", err)
|
||
}
|
||
if g.Players[0].Bet != 20 {
|
||
t.Errorf("ставка должна была удвоиться, получено %d", g.Players[0].Bet)
|
||
}
|
||
if g.Players[0].Balance != 80 {
|
||
t.Errorf("ожидался капитал 80 после доплаты ставки, получено %d", g.Players[0].Balance)
|
||
}
|
||
if len(g.Players[0].Hand) != 3 {
|
||
t.Errorf("после удвоения должна была добавиться ровно 1 карта, получено %d карт", len(g.Players[0].Hand))
|
||
}
|
||
if g.Players[0].Status != BJStatusStood {
|
||
t.Errorf("после удвоения (11+король=21, не перебор) статус должен быть stood, получено %v", g.Players[0].Status)
|
||
}
|
||
}
|
||
|
||
func TestDoubleDownRejectsWithMoreThanTwoCards(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts), bc(Two, Diamonds)}
|
||
g.Players[0].Status = BJStatusPlaying
|
||
g.CurrentPlayerIdx = 0
|
||
g.Phase = BJPhasePlayerTurn
|
||
|
||
if err := g.DoubleDown(); err != ErrBJCannotDouble {
|
||
t.Errorf("ожидалась ошибка ErrBJCannotDouble, получено: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDoubleDownRejectsInsufficientFunds(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(Five, Clubs), bc(Six, Hearts)}
|
||
g.Players[0].Status = BJStatusPlaying
|
||
g.Players[0].Balance = 5 // меньше ставки
|
||
g.Players[0].Bet = 10
|
||
g.CurrentPlayerIdx = 0
|
||
g.Phase = BJPhasePlayerTurn
|
||
|
||
if err := g.DoubleDown(); err != ErrBJNotEnoughFunds {
|
||
t.Errorf("ожидалась ошибка ErrBJNotEnoughFunds, получено: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPlayerBlackjackBeatsDealerNonBlackjack(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)}
|
||
g.Players[0].Status = BJStatusBlackjack
|
||
g.Dealer = []Card{bc(Nine, Clubs), bc(Seven, Hearts)} // 16, дилер должен добрать
|
||
g.Deck = &Deck{cards: []Card{bc(Two, Diamonds)}} // 16+2=18, останов
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
if g.Phase != BJPhaseRoundOver {
|
||
t.Fatalf("раунд должен был завершиться")
|
||
}
|
||
res := g.Result.PlayerResults[0]
|
||
if res.Outcome != "blackjack" {
|
||
t.Fatalf("ожидался исход blackjack, получено %s", res.Outcome)
|
||
}
|
||
// ставка 10: блэкджек платит 3:2 -> получает 10+15=25, было 90 (после
|
||
// списания ставки) -> дельта относительно ДО раунда (100): +15
|
||
if res.Delta != 15 {
|
||
t.Errorf("ожидалась дельта +15, получено %d", res.Delta)
|
||
}
|
||
}
|
||
|
||
func TestPushWhenBothBlackjack(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(Ace, Clubs), bc(King, Hearts)}
|
||
g.Players[0].Status = BJStatusBlackjack
|
||
g.Dealer = []Card{bc(Ace, Diamonds), bc(Queen, Spades)}
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
res := g.Result.PlayerResults[0]
|
||
if res.Outcome != "push" {
|
||
t.Fatalf("ожидался push при блэкджеке у обоих, получено %s", res.Outcome)
|
||
}
|
||
if res.Delta != 0 {
|
||
t.Errorf("ожидалась нулевая дельта при push, получено %d", res.Delta)
|
||
}
|
||
}
|
||
|
||
func TestDealerBustPaysStandingPlayer(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19
|
||
g.Players[0].Status = BJStatusStood
|
||
g.Dealer = []Card{bc(King, Diamonds), bc(Six, Spades)} // 16, обязан добрать
|
||
g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // 16+10=26, перебор
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
if !g.Result.DealerBusted {
|
||
t.Fatalf("дилер должен был перебрать")
|
||
}
|
||
res := g.Result.PlayerResults[0]
|
||
if res.Outcome != "win" {
|
||
t.Fatalf("ожидался win при переборе дилера, получено %s", res.Outcome)
|
||
}
|
||
if res.Delta != 10 {
|
||
t.Errorf("ожидалась дельта +10 (выигрыш 1:1), получено %d", res.Delta)
|
||
}
|
||
}
|
||
|
||
func TestBustedPlayerLosesRegardlessOfDealer(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(King, Clubs), bc(Queen, Hearts), bc(Five, Diamonds)} // 25
|
||
g.Players[0].Status = BJStatusBusted
|
||
g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
res := g.Result.PlayerResults[0]
|
||
if res.Outcome != "bust" {
|
||
t.Fatalf("ожидался исход bust, получено %s", res.Outcome)
|
||
}
|
||
if res.Delta != -10 {
|
||
t.Errorf("ожидалась дельта -10 (потеря ставки), получено %d", res.Delta)
|
||
}
|
||
}
|
||
|
||
func TestPushOnEqualTotals(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19
|
||
g.Players[0].Status = BJStatusStood
|
||
g.Dealer = []Card{bc(King, Diamonds), bc(Nine, Spades)} // 19, останов без добора
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
res := g.Result.PlayerResults[0]
|
||
if res.Outcome != "push" {
|
||
t.Fatalf("ожидался push при равных суммах, получено %s", res.Outcome)
|
||
}
|
||
if res.Delta != 0 {
|
||
t.Errorf("ожидалась нулевая дельта, получено %d", res.Delta)
|
||
}
|
||
}
|
||
|
||
func TestDealerStandsOnAllSeventeens(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A"}, -1, 100, 10)
|
||
resetForManualBout(g)
|
||
g.Players[0].Hand = []Card{bc(King, Clubs), bc(Nine, Hearts)} // 19
|
||
g.Players[0].Status = BJStatusStood
|
||
// мягкие 17: туз + 6 = 17. Дилер должен остановиться, не добирая.
|
||
g.Dealer = []Card{bc(Ace, Diamonds), bc(Six, Spades)}
|
||
g.Deck = &Deck{cards: []Card{bc(King, Hearts)}} // если бы дилер тянул — перебор
|
||
g.CurrentPlayerIdx = len(g.Players)
|
||
g.advanceToNextPlayerOrResolve()
|
||
|
||
if len(g.Dealer) != 2 {
|
||
t.Fatalf("дилер не должен был тянуть карту на мягких 17, рука: %v", g.Dealer)
|
||
}
|
||
if g.Result.DealerTotal != 17 {
|
||
t.Errorf("ожидалась сумма дилера 17, получено %d", g.Result.DealerTotal)
|
||
}
|
||
}
|
||
|
||
func TestNextRoundCarriesOverBalance(t *testing.T) {
|
||
g := NewBlackjackGame([]string{"A", "B"}, 0, 100, 10)
|
||
g.Players[0].Balance = 150
|
||
g.Players[1].Balance = 42
|
||
|
||
next := g.NextRound()
|
||
// Капитал должен перенестись, а новая ставка — списаться заново.
|
||
// Проверяем именно preRoundBalance (капитал ДО ставки нового
|
||
// раунда), а не текущий Balance: если кому-то в новом раунде
|
||
// случайно выпадет блэкджек, конструктор сам мгновенно разрешит
|
||
// раунд и Balance уже отразит и ставку, и выплату — это ожидаемое
|
||
// поведение движка, но не то, что здесь проверяется.
|
||
if next.preRoundBalance[0] != 150 {
|
||
t.Errorf("ожидался перенесённый капитал 150 до ставки, получено %d", next.preRoundBalance[0])
|
||
}
|
||
if next.preRoundBalance[1] != 42 {
|
||
t.Errorf("ожидался перенесённый капитал 42 до ставки, получено %d", next.preRoundBalance[1])
|
||
}
|
||
if next.Players[0].Balance != 140 && next.Phase == BJPhasePlayerTurn {
|
||
t.Errorf("до какого-либо разрешения раунда ожидался капитал 140 (150-10), получено %d", next.Players[0].Balance)
|
||
}
|
||
if !next.Players[0].IsHuman {
|
||
t.Errorf("флаг IsHuman должен был сохраниться при переносе")
|
||
}
|
||
}
|
||
|
||
// TestBlackjackFullSimulation прогоняет много полных раундов с
|
||
// ботами разного числа игроков, проверяя отсутствие паник и
|
||
// корректность (капитал не должен непредсказуемо телепортироваться:
|
||
// сумма всех дельт равна сумме (dealer-neutral, т.е. каждый платится
|
||
// независимо от банка, поэтому проверяем только отсутствие паник и
|
||
// что результат всегда присутствует).
|
||
func TestBlackjackFullSimulation(t *testing.T) {
|
||
bot := BlackjackBot{}
|
||
for numPlayers := 1; numPlayers <= 6; numPlayers++ {
|
||
for round := 0; round < 50; round++ {
|
||
names := make([]string, numPlayers)
|
||
for i := range names {
|
||
names[i] = "Bot"
|
||
}
|
||
g := NewBlackjackGame(names, -1, 1000, 10)
|
||
|
||
const maxSteps = 100
|
||
steps := 0
|
||
for ; steps < maxSteps && g.Phase != BJPhaseRoundOver; steps++ {
|
||
if err := bot.PlayFullTurn(g); err != nil {
|
||
t.Fatalf("игроков %d, раунд %d: ошибка бота: %v", numPlayers, round, err)
|
||
}
|
||
}
|
||
if steps >= maxSteps {
|
||
t.Fatalf("игроков %d, раунд %d: раунд не завершился за %d шагов", numPlayers, round, maxSteps)
|
||
}
|
||
if g.Result == nil {
|
||
t.Fatalf("игроков %d, раунд %d: раунд завершился без результата", numPlayers, round)
|
||
}
|
||
if len(g.Result.PlayerResults) != numPlayers {
|
||
t.Fatalf("игроков %d, раунд %d: ожидалось %d результатов, получено %d",
|
||
numPlayers, round, numPlayers, len(g.Result.PlayerResults))
|
||
}
|
||
}
|
||
}
|
||
}
|