Fix bugs around AI opponents.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
"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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import (
|
|||||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
"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
|
// applyTemplateShopEffects mirrors the engine's shop-time triggers on a
|
||||||
// hypothetical deck: buying an Otter really does come with an apple, and the
|
// hypothetical deck: buying an Otter really does come with an apple, and the
|
||||||
// bot should value that. Only deck-changing effects matter here (coin
|
// 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 = append(nd, cx.simApple())
|
||||||
nd = cx.applyTemplateShopEffects(nd, s, game.TriggerSell)
|
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{
|
cands = append(cands, candidate{
|
||||||
act: &Action{Type: "sell", Cards: ids},
|
act: &Action{Type: "sell", Cards: ids},
|
||||||
decks: [][]game.Card{nd},
|
decks: [][]game.Card{nd},
|
||||||
|
|||||||
@@ -1290,11 +1290,14 @@ func (g *Game) runBattle() *BattleResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single side still holding a pet in play wins; anything else
|
// A single side that can still field a pet wins; anything else (everyone
|
||||||
// (everyone out, or a stalemate with pets on both sides) is a draw.
|
// 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
|
winner := -1
|
||||||
for seat, s := range sides {
|
for seat, s := range sides {
|
||||||
if s.unit != nil {
|
if s.canField() {
|
||||||
if winner >= 0 {
|
if winner >= 0 {
|
||||||
winner = -1 // stalemate / >2-player safety
|
winner = -1 // stalemate / >2-player safety
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -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.
|
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
|
||||||
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
||||||
g, _, _ := testGame(t)
|
g, _, _ := testGame(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user