Fix bugs around AI opponents.

This commit is contained in:
Greyson Parrelli
2026-07-24 12:52:10 -04:00
parent add2218978
commit e25a85e394
4 changed files with 95 additions and 3 deletions
+55
View File
@@ -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)
}
}
}
+17
View File
@@ -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},