diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index a373eab..9bff254 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -1,6 +1,8 @@ package ai import ( + "fmt" + "slices" "testing" "github.com/greyson/super-auto-pets-board-game/internal/game" @@ -251,3 +253,56 @@ func TestSimulateBattleIsPure(t *testing.T) { t.Error("SimulateBattle mutated its input decks") } } + +// TestDecideShopNeverSellsLastPet guards the invariant that the bot never +// voluntarily turns its whole deck into food. In a hopeless late-game spot +// (final round, a strong modeled opponent, so every rollout is a loss) the +// candidate scores all collapse toward zero and the softmax degenerates to a +// near-uniform pick — the exact situation that once let the bot sell every pet +// across a few shop turns and hand over an automatic loss. However the dice +// fall, a returned sell must leave at least one pet standing. +func TestDecideShopNeverSellsLastPet(t *testing.T) { + pet := func(id, name string, power int) game.Card { + return game.Card{ID: id, Kind: game.KindPet, Name: name, Tier: 1, Power: power, Suit: game.SuitRed} + } + v := &game.View{ + Phase: game.PhaseShop, + Round: game.MaxRounds, // alpha == 1: score is win-now only + MaxRounds: game.MaxRounds, + MaxPets: game.MaxPets, + Pack: game.DefaultPack, + YouSeat: 0, + Turn: 0, + PrioritySeat: 0, + DeckCounts: make([]int, game.MaxRounds+1), + Players: []game.PlayerView{ + {Seat: 0, Coins: 0, PetCount: 2, DeckSize: 2, // no coins: buying is off the table + Deck: []game.Card{pet("p1", "Ant", 1), pet("p2", "Cricket", 1)}}, + {Seat: 1, PetCount: 5, DeckSize: 5}, + }, + } + // Model a crushing opponent so every simulated battle is a loss. + mem := &Memory{} + mem.Opp.Seat = 1 + for i := range 5 { + mem.Opp.Known = append(mem.Opp.Known, + game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50}) + } + + bot := New(0.5) // medium difficulty — the level from the bug report + for i := range 400 { + act := bot.decideShop(v, mem) + if act.Type != "sell" { + continue + } + remaining := 0 + for _, c := range v.Players[0].Deck { + if c.IsPet() && !slices.Contains(act.Cards, c.ID) { + remaining++ + } + } + if remaining == 0 { + t.Fatalf("iter %d: bot sold its last pet(s) %v, leaving an all-food deck", i, act.Cards) + } + } +} diff --git a/internal/ai/shop.go b/internal/ai/shop.go index 75a0106..b2046a9 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -7,6 +7,18 @@ import ( "github.com/greyson/super-auto-pets-board-game/internal/game" ) +// deckHasPet reports whether a hypothetical deck still contains at least one +// pet. A deck of only food (apples) can never field a fighter, so it is an +// automatic loss — the bot must never voluntarily sell or trade its way there. +func deckHasPet(deck []game.Card) bool { + for _, c := range deck { + if c.IsPet() { + return true + } + } + return false +} + // applyTemplateShopEffects mirrors the engine's shop-time triggers on a // hypothetical deck: buying an Otter really does come with an apple, and the // bot should value that. Only deck-changing effects matter here (coin @@ -240,6 +252,11 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { nd = append(nd, cx.simApple()) nd = cx.applyTemplateShopEffects(nd, s, game.TriggerSell) } + // Never dump the last pet: an all-food deck loses on sight, so this + // candidate is off the table no matter how the rollouts score. + if !deckHasPet(nd) { + continue + } cands = append(cands, candidate{ act: &Action{Type: "sell", Cards: ids}, decks: [][]game.Card{nd}, diff --git a/internal/game/battle.go b/internal/game/battle.go index b418c47..ff13512 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -1290,11 +1290,14 @@ func (g *Game) runBattle() *BattleResult { } } - // A single side still holding a pet in play wins; anything else - // (everyone out, or a stalemate with pets on both sides) is a draw. + // A single side that can still field a pet wins; anything else (everyone + // out, or a stalemate with pets on both sides) is a draw. We test canField, + // not unit, because the loop can break the instant one side runs out while + // the other's current pet has just fainted — that side still has pets left + // in its stack (it simply wasn't refilled) and is the rightful winner. winner := -1 for seat, s := range sides { - if s.unit != nil { + if s.canField() { if winner >= 0 { winner = -1 // stalemate / >2-player safety break diff --git a/internal/game/golden45_test.go b/internal/game/golden45_test.go index 9336772..109f65b 100644 --- a/internal/game/golden45_test.go +++ b/internal/game/golden45_test.go @@ -156,6 +156,23 @@ func TestManateeSelfRockFatalStillAddsApples(t *testing.T) { } } +// A player whose only in-play pet faints to its own play effect (Manatee's +// self-rock) still wins if it has pets left in its deck and the opponent has +// none. The battle loop breaks the instant the opponent runs out — before the +// survivor's next pet is revealed — so the winner must be decided by who can +// still field a pet, not by who happens to have one in play at that instant. +func TestSelfFaintWithReserveStillWins(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // 2 rocks = 4 damage, fatal to the 3-power Manatee + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Manatee"), g.pet("Reserve", 5)}, + []Card{g.newApple()}, // opponent has only food — no pet, ever + ) + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win with a reserve pet vs an all-food deck, got winner %d", res.WinnerSeat) + } +} + // Poison Dart Frog, once set aside, throws rocks each time a Bee is played. func TestPoisonDartFrogBeeRocks(t *testing.T) { g, _, _ := testGame(t)