go_games_collection/game_test.go
2026-07-07 09:12:09 +03:00

454 lines
18 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 (
"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)
}
}
}
}