454 lines
18 KiB
Go
454 lines
18 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|
||
}
|