go_games_collection/blackjack_game_test.go
2026-07-04 13:51:04 +03:00

358 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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