package main import ( "fmt" "strings" "testing" ) func TestNewGameDealsCorrectly(t *testing.T) { g := NewGame([]string{"A", "B", "C"}, -1) if len(g.Players) != 3 { t.Fatalf("ожидалось 3 игрока, получено %d", len(g.Players)) } for _, p := range g.Players { if len(p.Hand) != 5 { t.Errorf("у игрока %s ожидалось 5 карт, получено %d", p.Name, len(p.Hand)) } } if _, ok := g.PeekDiscard(); !ok { t.Error("ожидалась вскрытая карта в сбросе после раздачи") } // 3*5 карт игрокам + 1 в сброс = 16, в колоде должно остаться 36 if g.Deck.Len() != 52-15-1 { t.Errorf("неверное количество карт в колоде: %d", g.Deck.Len()) } } func TestDrawDiscardCycle(t *testing.T) { g := NewGame([]string{"A", "B"}, -1) handBefore := len(g.current().Hand) if _, err := g.DrawFromDeck(); err != nil { t.Fatalf("неожиданная ошибка при взятии карты: %v", err) } if len(g.current().Hand) != handBefore+1 { t.Fatalf("рука должна была вырасти на 1 карту") } if g.Phase != PhaseAction { t.Fatalf("после взятия карты ожидалась фаза PhaseAction, получена %v", g.Phase) } card := g.current().Hand[0] if err := g.Discard(card); err != nil { t.Fatalf("неожиданная ошибка при сбросе: %v", err) } if g.CurrentPlayer != 1 { t.Fatalf("ход должен был перейти ко второму игроку, текущий: %d", g.CurrentPlayer) } if g.Phase != PhaseDraw { t.Fatalf("после сброса ожидалась фаза PhaseDraw, получена %v", g.Phase) } } func TestWrongPhaseRejected(t *testing.T) { g := NewGame([]string{"A", "B"}, -1) // ещё не брали карту — сброс должен быть запрещён if err := g.Discard(g.current().Hand[0]); err != ErrWrongPhase { t.Errorf("ожидалась ошибка ErrWrongPhase, получено: %v", err) } // повторное взятие карты в фазе PhaseDraw -> PhaseAction, затем // повторный DrawFromDeck должен быть отклонён if _, err := g.DrawFromDeck(); err != nil { t.Fatalf("неожиданная ошибка: %v", err) } if _, err := g.DrawFromDeck(); err != ErrWrongPhase { t.Errorf("повторное взятие карты в фазе PhaseAction должно быть запрещено, получено: %v", err) } } func TestEndByEmptyHandTakesPot(t *testing.T) { g := NewGameWithStakes([]string{"A", "B", "C"}, -1, 100, 10, 2) // банк уже собран в NewGameWithStakes: 3 игрока * 10 = 30 if g.Pot != 30 { t.Fatalf("ожидался банк 30 после сбора анте, получено %d", g.Pot) } for _, p := range g.Players { if p.Balance != 90 { t.Fatalf("ожидался капитал 90 после анте, получено %d у %s", p.Balance, p.Name) } } g.Players[0].Hand = []Card{c(Five, Hearts)} g.CurrentPlayer = 0 g.Phase = PhaseAction if err := g.Discard(c(Five, Hearts)); err != nil { t.Fatalf("неожиданная ошибка при сбросе: %v", err) } if g.Phase != PhaseGameOver { t.Fatalf("раунд должен был завершиться победой игрока 0") } if g.Result.Winner != 0 { t.Fatalf("ожидался победитель 0, получен %d", g.Result.Winner) } if g.Result.Pot != 30 { t.Fatalf("ожидался банк раунда 30, получено %d", g.Result.Pot) } // победитель: было 90, получил банк 30 -> 120; итоговое изменение // относительно капитала ДО анте (100): +20 if g.Players[0].Balance != 120 { t.Errorf("ожидался капитал победителя 120, получено %d", g.Players[0].Balance) } if g.Result.BalanceDeltas[0] != 20 { t.Errorf("ожидалось изменение капитала +20 у победителя, получено %d", g.Result.BalanceDeltas[0]) } for i := 1; i < 3; i++ { if g.Players[i].Balance != 90 { t.Errorf("проигравший %d должен остаться с капиталом 90, получено %d", i, g.Players[i].Balance) } if g.Result.BalanceDeltas[i] != -10 { t.Errorf("проигравший %d должен потерять анте (-10), получено %d", i, g.Result.BalanceDeltas[i]) } } } func TestDeadRoundRefundsAnte(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) g.endDeadRound() if !g.Result.DeadRound { t.Fatalf("ожидался DeadRound=true") } for i, p := range g.Players { if p.Balance != 100 { t.Errorf("капитал игрока %d должен был вернуться к 100, получено %d", i, p.Balance) } if g.Result.BalanceDeltas[i] != 0 { t.Errorf("изменение капитала при мёртвом раунде должно быть 0, получено %d", g.Result.BalanceDeltas[i]) } } } func TestDropSucceedsWhenStrictlyLowest(t *testing.T) { g := NewGameWithStakes([]string{"A", "B", "C"}, -1, 100, 10, 2) // банк = 30 (3*10), капитал каждого сейчас 90 g.Players[0].Hand = []Card{c(Two, Clubs)} // вес 2 — строго ниже всех g.Players[1].Hand = []Card{c(King, Hearts), c(Nine, Diamonds)} // вес 14 g.Players[2].Hand = []Card{c(Queen, Spades)} // вес 10 g.CurrentPlayer = 0 g.Phase = PhaseDraw res, err := g.Drop() if err != nil { t.Fatalf("неожиданная ошибка дропа: %v", err) } if !res.DropCall || res.DropCaught { t.Fatalf("ожидался успешный дроп: %+v", res) } if res.Winner != 0 { t.Errorf("ожидался победитель 0, получено %d", res.Winner) } // объявивший забирает банк целиком (без множителя): 90+30=120, // дельта +20 относительно стартовых 100 if g.Players[0].Balance != 120 { t.Errorf("ожидался капитал 120 у объявившего, получено %d", g.Players[0].Balance) } if g.Players[1].Balance != 90 || g.Players[2].Balance != 90 { t.Errorf("остальные должны были просто остаться при своём анте (90), получено %d и %d", g.Players[1].Balance, g.Players[2].Balance) } if res.BalanceDeltas[0] != 20 || res.BalanceDeltas[1] != -10 || res.BalanceDeltas[2] != -10 { t.Errorf("неверный расчёт изменения капитала при успешном дропе: %v", res.BalanceDeltas) } } func TestDropCaughtByLowerOpponent(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) // банк = 20, капитал каждого сейчас 90 g.Players[0].Hand = []Card{c(King, Hearts)} // вес 4 — думает, что мало g.Players[1].Hand = []Card{c(Ace, Clubs)} // вес 1 — реально ниже g.CurrentPlayer = 0 g.Phase = PhaseDraw res, err := g.Drop() if err != nil { t.Fatalf("неожиданная ошибка дропа: %v", err) } if !res.DropCall || !res.DropCaught { t.Fatalf("ожидался неудачный дроп (попался): %+v", res) } if res.Winner != 1 { t.Errorf("ожидался победитель 1 (реально ниже), получено %d", res.Winner) } // игрок 1 получает банк (20) плюс штраф с объявившего (анте=10): // 90+20+10=120, дельта +20 if g.Players[1].Balance != 120 { t.Errorf("ожидался капитал 120 у поймавшего, получено %d", g.Players[1].Balance) } // объявивший теряет анте (уже потрачено) и вдобавок платит штраф // в размере анте: 90-10=80, дельта -20 относительно стартовых 100 if g.Players[0].Balance != 80 { t.Errorf("ожидался капитал 80 у объявившего (попался), получено %d", g.Players[0].Balance) } if res.BalanceDeltas[0] != -20 || res.BalanceDeltas[1] != 20 { t.Errorf("неверный расчёт изменения капитала при неудачном дропе: %v", res.BalanceDeltas) } } func TestDropTieWithAnotherPlayerCountsAsCaught(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) g.Players[0].Hand = []Card{c(Ace, Clubs)} // вес 1 g.Players[1].Hand = []Card{c(Ace, Hearts)} // тоже вес 1 — ничья не в пользу объявившего g.CurrentPlayer = 0 g.Phase = PhaseDraw res, err := g.Drop() if err != nil { t.Fatalf("неожиданная ошибка дропа: %v", err) } if !res.DropCaught { t.Errorf("при равенстве весов дроп должен считаться неудачным (нужно строго меньше всех)") } if res.Winner != 1 { t.Errorf("ожидался победитель 1, получено %d", res.Winner) } } func TestDropOnlyAllowedBeforeDrawing(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) g.CurrentPlayer = 0 g.Phase = PhaseAction // уже взяли карту — дропать поздно if _, err := g.Drop(); err != ErrWrongPhase { t.Errorf("ожидалась ошибка ErrWrongPhase, получено: %v", err) } } func TestDropZeroSumAcrossManyPlayers(t *testing.T) { g := NewGameWithStakes([]string{"A", "B", "C", "D"}, -1, 100, 7, 2) g.Players[0].Hand = []Card{c(Five, Clubs)} g.Players[1].Hand = []Card{c(Three, Hearts)} g.Players[2].Hand = []Card{c(Three, Spades)} // ничья с игроком 1 за самую низкую g.Players[3].Hand = []Card{c(King, Diamonds)} g.CurrentPlayer = 0 g.Phase = PhaseDraw res, err := g.Drop() if err != nil { t.Fatalf("неожиданная ошибка дропа: %v", err) } sum := 0 for _, d := range res.BalanceDeltas { sum += d } if sum != 0 { t.Errorf("сумма изменений капитала при дропе должна быть 0, получено %d (%v)", sum, res.BalanceDeltas) } } func TestDeclareTonk(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) // банк = 20 (2 игрока * 10), капитал каждого сейчас 90 g.Players[0].Hand = []Card{c(Ace, Hearts), c(Two, Clubs)} // вес 3 <= порога g.CurrentPlayer = 0 g.Phase = PhaseAction res, err := g.DeclareTonk() if err != nil { t.Fatalf("неожиданная ошибка объявления Тонка: %v", err) } if !res.TonkCall || res.Winner != 0 { t.Fatalf("ожидался Тонк от игрока 0: %+v", res) } // банк ×2 = 40; победитель получает 40 (90+40=130, дельта +30 // относительно стартовых 100); единственный проигравший // доплачивает разницу (extra=20) сверх уже отданного анте: // 90-20=70, дельта -30 относительно стартовых 100 if g.Players[0].Balance != 130 { t.Errorf("ожидался капитал победителя 130, получено %d", g.Players[0].Balance) } if g.Players[1].Balance != 70 { t.Errorf("ожидался капитал проигравшего 70, получено %d", g.Players[1].Balance) } if res.BalanceDeltas[0] != 30 || res.BalanceDeltas[1] != -30 { t.Errorf("неверный расчёт изменения капитала при Тонке: %v", res.BalanceDeltas) } } func TestDeclareTonkRejectedAboveThreshold(t *testing.T) { g := NewGame([]string{"A", "B"}, -1) g.Players[0].Hand = []Card{c(King, Hearts), c(Nine, Clubs)} // вес 19 g.CurrentPlayer = 0 g.Phase = PhaseAction if _, err := g.DeclareTonk(); err != ErrTonkNotAllowed { t.Errorf("ожидалась ошибка ErrTonkNotAllowed, получено: %v", err) } } func TestLayNewMeldAndAddToMeld(t *testing.T) { g := NewGame([]string{"A", "B"}, -1) g.Players[0].Hand = []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades), c(Two, Diamonds)} g.CurrentPlayer = 0 g.Phase = PhaseAction set := []Card{c(Seven, Clubs), c(Seven, Hearts), c(Seven, Spades)} if err := g.LayNewMeld(set); err != nil { t.Fatalf("неожиданная ошибка выкладки сета: %v", err) } if len(g.Table) != 1 { t.Fatalf("ожидалась 1 комбинация на столе, получено %d", len(g.Table)) } if len(g.current().Hand) != 1 { t.Fatalf("после выкладки в руке должна остаться 1 карта, получено %d", len(g.current().Hand)) } // второй игрок подкладывает четвёртую семёрку g.Players[1].Hand = []Card{c(Seven, Diamonds)} g.CurrentPlayer = 1 g.Phase = PhaseAction if err := g.AddToMeld(0, c(Seven, Diamonds)); err != nil { t.Fatalf("неожиданная ошибка подкладки: %v", err) } if len(g.Table[0].Cards) != 4 { t.Errorf("ожидалось 4 карты в комбинации после подкладки, получено %d", len(g.Table[0].Cards)) } } // TestFullBotGames прогоняет много полных партий силами ботов всех // трёх уровней в разных сочетаниях и проверяет, что игра всегда // корректно завершается (без паники и без зависания) и что очки // нулевой суммы (кроме мёртвых раундов). // allCardsAccountedFor проверяет, что все 52 карты стандартной // колоды встречаются в игре РОВНО один раз суммарно по: колоде, // сбросу, рукам игроков и комбинациям на столе. Возвращает список // описаний найденных нарушений (пустой — всё в порядке). func allCardsAccountedFor(g *GameState) []string { counts := map[Card]int{} locations := map[Card][]string{} record := func(c Card, where string) { counts[c]++ locations[c] = append(locations[c], where) } for _, c := range g.Deck.cards { record(c, "колода") } for _, c := range g.DiscardPile { record(c, "сброс") } for i, p := range g.Players { for _, c := range p.Hand { record(c, fmt.Sprintf("рука игрока %d", i)) } } for mi, meld := range g.Table { for _, c := range meld.Cards { record(c, fmt.Sprintf("комбинация №%d на столе", mi)) } } var problems []string fullDeck := NewDeck() for _, c := range fullDeck.cards { switch counts[c] { case 1: case 0: problems = append(problems, fmt.Sprintf("карта %s пропала — нигде не найдена", c)) default: problems = append(problems, fmt.Sprintf("карта %s встречается %d раз(а) — в: %v", c, counts[c], locations[c])) } } return problems } // TestCardIntegrityAcrossFullBotGames — по мотивам реального // наблюдения пользователя (одна и та же карта якобы одновременно // в сбросе и в комбинации на столе): прогоняет много полных партий // разными сочетаниями ботов, проверяя после КАЖДОГО хода, что все // 52 карты стандартной колоды учтены ровно по одному разу — нигде // не потеряны и нигде не задублированы. func TestCardIntegrityAcrossFullBotGames(t *testing.T) { configs := [][]BotStrategy{ {SimpleBot{}, SimpleBot{}, SimpleBot{}, SimpleBot{}}, {MediumBot{}, MediumBot{}, MediumBot{}, MediumBot{}}, {TonkBot{}, TonkBot{}, TonkBot{}, TonkBot{}}, {SimpleBot{}, MediumBot{}, TonkBot{}, SimpleBot{}}, } for cfgIdx, bots := range configs { for game := 0; game < 50; game++ { names := make([]string, len(bots)) for i := range names { names[i] = "Bot" } g := NewGame(names, -1) const maxTurns = 1000 for turn := 0; turn < maxTurns && g.Phase != PhaseGameOver; turn++ { bot := bots[g.CurrentPlayer] if err := bot.PlayTurn(g); err != nil { t.Fatalf("конфиг %d, игра %d, ход %d: ошибка хода бота: %v", cfgIdx, game, turn, err) } if problems := allCardsAccountedFor(g); len(problems) > 0 { t.Fatalf("конфиг %d, игра %d, ход %d: нарушена целостность колоды:\n%s", cfgIdx, game, turn, strings.Join(problems, "\n")) } } if g.Phase == PhaseGameOver { g2 := g.NextRound() if problems := allCardsAccountedFor(g2); len(problems) > 0 { t.Fatalf("конфиг %d, игра %d: нарушена целостность колоды сразу после NextRound():\n%s", cfgIdx, game, strings.Join(problems, "\n")) } } } } } func TestFullBotGames(t *testing.T) { configs := [][]BotStrategy{ {SimpleBot{}, SimpleBot{}, SimpleBot{}, SimpleBot{}}, {MediumBot{}, MediumBot{}, MediumBot{}, MediumBot{}}, {TonkBot{}, TonkBot{}, TonkBot{}, TonkBot{}}, {SimpleBot{}, MediumBot{}, TonkBot{}, SimpleBot{}}, } for cfgIdx, bots := range configs { for game := 0; game < 20; game++ { names := make([]string, len(bots)) for i := range names { names[i] = "Bot" } g := NewGame(names, -1) const maxTurns = 1000 turn := 0 for ; turn < maxTurns && g.Phase != PhaseGameOver; turn++ { bot := bots[g.CurrentPlayer] if err := bot.PlayTurn(g); err != nil { t.Fatalf("конфиг %d, игра %d: ошибка на ходу бота %d: %v", cfgIdx, game, g.CurrentPlayer, err) } } if turn >= maxTurns { t.Fatalf("конфиг %d, игра %d: партия не завершилась за %d ходов (возможное зависание)", cfgIdx, game, maxTurns) } if g.Result == nil { t.Fatalf("конфиг %d, игра %d: партия завершилась без результата", cfgIdx, game) } sum := 0 for _, d := range g.Result.BalanceDeltas { sum += d } if sum != 0 { t.Errorf("конфиг %d, игра %d: сумма изменений капитала должна быть 0, получено %d (%v)", cfgIdx, game, sum, g.Result.BalanceDeltas) } } } }