Files
super-auto-pets-board-game/internal/ai/ai_test.go
T

404 lines
13 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 ai
import (
"fmt"
"slices"
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// playBotGame drives a full game with bots in both seats, the same way the
// server would: observe on every state change, then act when input is owed.
// It fails the test if a bot ever produces an illegal action or the game
// stops making progress.
func playBotGame(t *testing.T, levelA, levelB float64) *game.Game {
return playBotGamePack(t, game.DefaultPack, levelA, levelB)
}
// forcePlayable temporarily marks a (possibly gated) pack Playable so tests can
// start a game on it, returning a restore func.
func forcePlayable(id string) func() {
for i := range game.Packs {
if game.Packs[i].ID == id {
prev := game.Packs[i].Playable
game.Packs[i].Playable = true
idx := i
return func() { game.Packs[idx].Playable = prev }
}
}
return func() {}
}
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
t.Helper()
defer forcePlayable(pack)()
g := game.New()
pa, err := g.AddBot("Bot A", levelA)
if err != nil {
t.Fatalf("AddBot A: %v", err)
}
pb, err := g.AddBot("Bot B", levelB)
if err != nil {
t.Fatalf("AddBot B: %v", err)
}
if err := g.SetPack(pack); err != nil {
t.Fatalf("SetPack %s: %v", pack, err)
}
if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err)
}
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
observe := func() {
for _, p := range g.Players {
v := g.ViewFor(p.ID)
Observe(&v, mems[p.ID])
}
}
observe()
for steps := 0; g.Phase != game.PhaseGameOver; steps++ {
if steps > 2000 {
t.Fatalf("game made no progress; stuck in phase %s round %d", g.Phase, g.Round)
}
acted := false
for _, p := range g.Players {
v := g.ViewFor(p.ID)
if !Pending(&v) {
continue
}
act := bots[p.ID].Act(&v, mems[p.ID])
if act == nil {
t.Fatalf("bot %s owes an action in phase %s but returned none", p.Name, g.Phase)
}
if err := applyAction(g, p.ID, act); err != nil {
t.Fatalf("bot %s illegal action %q in phase %s round %d: %v", p.Name, act.Type, g.Phase, g.Round, err)
}
observe()
acted = true
break // one action per iteration, like one message per broadcast
}
if !acted {
t.Fatalf("no bot owes an action but the game is not over (phase %s)", g.Phase)
}
}
return g
}
// applyAction mirrors the server's dispatch of bot actions onto the engine.
func applyAction(g *game.Game, playerID string, a *Action) error {
switch a.Type {
case "buy":
return g.Buy(playerID, a.Row)
case "buyAvocado":
return g.BuyAvocado(playerID, a.Row)
case "sell":
return g.Sell(playerID, a.Cards)
case "trade":
return g.TradeStart(playerID, a.Cards)
case "tradeChoose":
return g.TradeChoose(playerID, a.Pick)
case "revealChoose":
return g.RevealChoose(playerID, a.CardID)
case "sacrificeChoose":
return g.SacrificeChoose(playerID, a.CardID)
case "pass":
return g.Pass(playerID)
case "arrange":
return g.SubmitOrder(playerID, a.Order)
case "ready":
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
// TestBotsFinishGames plays complete games at each difficulty pairing. This
// is the main safety net: every phase, every action type, every round, with
// two independent AIs generating whatever situations they generate.
func TestBotsFinishGames(t *testing.T) {
for _, levels := range [][2]float64{{1, 1}, {0.25, 1}, {0, 0}, {0.6, 0.25}} {
for range 3 {
g := playBotGame(t, levels[0], levels[1])
if g.Round != game.MaxRounds {
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
}
}
}
}
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
// battle mechanics via SimulateBattle rollouts and the new shop effects, and
// fails on any illegal or missing bot action.
func TestBotsFinishGoldenGame(t *testing.T) {
for range 5 {
g := playBotGamePack(t, "golden", 1, 0.6)
if g.Round != game.MaxRounds {
t.Errorf("golden game ended on round %d, want %d", g.Round, game.MaxRounds)
}
}
}
// TestBotsFinishUnicornGame plays complete games on the Unicorn pack (tiers
// 1-3 printed; 4-6 still in progress, so late shops are barren but battles
// still resolve). It exercises the Mana and Ailment mechanics via
// SimulateBattle rollouts plus the new shop effects (Water of Youth's sacrifice
// choice, Bigfoot), and fails on any illegal or missing bot action.
func TestBotsFinishUnicornGame(t *testing.T) {
for range 5 {
g := playBotGamePack(t, "unicorn", 1, 0.6)
if g.Round != game.MaxRounds {
t.Errorf("unicorn game ended on round %d, want %d", g.Round, game.MaxRounds)
}
}
}
// TestObserveTracksOpponentDeck checks the memory's opponent model against
// the opponent's real deck after known public actions. The model may only
// contain information a human spectator would have.
func TestObserveTracksOpponentDeck(t *testing.T) {
g := game.New()
pa, _ := g.AddBot("Bot A", 1)
pb, _ := g.AddBot("Bot B", 1)
if err := g.StartGame(); err != nil {
t.Fatalf("StartGame: %v", err)
}
mem := &Memory{}
obs := func() {
v := g.ViewFor(pa.ID)
Observe(&v, mem)
}
obs()
// Whoever holds priority shops first; walk both players through buys,
// then have both pass to end the shop.
first, second := g.Players[g.PrioritySeat], g.Players[1-g.PrioritySeat]
for range 3 { // 3 coins each, alternating
for _, p := range []*game.Player{first, second} {
if err := g.Buy(p.ID, 0); err != nil {
t.Fatalf("buy: %v", err)
}
obs()
}
}
for _, p := range []*game.Player{first, second} {
if err := g.Pass(p.ID); err != nil {
t.Fatalf("pass: %v", err)
}
obs()
}
// The model of B's deck must now match B's real deck card-for-card:
// every buy was public (and buy effects like Otter's apple are printed
// on the card).
assertModelMatches(t, mem, pb)
// Play out the round; the battle lineup resync must also match.
if g.Phase != game.PhaseArrange {
t.Fatalf("phase = %s, want arrange after both pass", g.Phase)
}
for _, p := range g.Players {
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
if err := g.SubmitOrder(p.ID, ids); err != nil {
t.Fatalf("submit: %v", err)
}
obs()
}
if g.Phase != game.PhaseBattle {
t.Fatalf("phase = %s, want battle", g.Phase)
}
obs()
for _, p := range g.Players {
if err := g.AcknowledgeBattle(p.ID); err != nil {
t.Fatalf("ack: %v", err)
}
obs()
}
// Round 2 shop: temporaries expired; model must match B's real deck.
assertModelMatches(t, mem, pb)
}
// assertModelMatches requires the opponent model to agree with the real deck
// as a multiset of card names (IDs can legitimately differ for cards the bot
// reconstructed from public information).
func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
t.Helper()
want := map[string]int{}
for _, c := range opp.Deck {
want[c.Name]++
}
got := map[string]int{}
for _, c := range mem.Opp.Known {
got[c.Name]++
}
if len(mem.Opp.Hidden) != 0 {
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden))
}
for name, n := range want {
if got[name] != n {
t.Errorf("model has %d × %s, real deck has %d", got[name], name, n)
}
}
for name, n := range got {
if want[name] == 0 {
t.Errorf("model claims %d × %s that the real deck lacks", n, name)
}
}
}
// TestSimulateBattleIsPure verifies rollouts don't corrupt anything the
// caller hands in.
func TestSimulateBattleIsPure(t *testing.T) {
deckA := []game.Card{
{ID: "a1", Kind: game.KindPet, Name: "Ant", Power: 1,
Effects: []game.Effect{{Trigger: game.TriggerFaint, Action: game.ActionSummonTop, Card: "apple"}}},
}
deckB := []game.Card{
{ID: "b1", Kind: game.KindPet, Name: "Duck", Power: 2},
}
res := game.SimulateBattle(1, 0, deckA, deckB, nil)
if res == nil || res.WinnerSeat != 1 {
t.Fatalf("expected seat 1 (Duck) to win, got %+v", res)
}
if len(deckA) != 1 || len(deckB) != 1 || deckA[0].ID != "a1" || deckB[0].ID != "b1" {
t.Error("SimulateBattle mutated its input decks")
}
}
// TestBlunderCurve pins the difficulty calibration's shape: only sub-competent
// bots ever throw a game on purpose, and they do so more the weaker they are.
// Medium (== competentLevel) and hard must never blunder, or they would not be
// the even-match / favourite the difficulty design promises.
func TestBlunderCurve(t *testing.T) {
if p := blunderProb(competentLevel); p != 0 {
t.Errorf("competent bot blunders with p=%.3f, want 0", p)
}
if p := blunderProb(1.0); p != 0 {
t.Errorf("hard bot blunders with p=%.3f, want 0", p)
}
weakest, easy := blunderProb(0), blunderProb(0.25)
if !(weakest > easy && easy > 0) {
t.Errorf("blunder rate must rise as level falls: level0=%.3f easy=%.3f", weakest, easy)
}
if weakest > 1 {
t.Errorf("blunder probability %.3f exceeds 1", weakest)
}
}
// 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)
}
}
}
// TestDecideShopKeepsHealthyBoard guards against the slow-bleed bug: with its
// coins spent, the bot used to find that turning its worst pet into an apple
// scored marginally *better* than passing (the apple buffs a survivor for one
// battle; the permanent loss of a body barely dented the squashed future-value
// term). Repeated every shop turn, that shed the board down to a single pet and
// an all-apple hand — an automatic loss, since apples don't carry between
// rounds. A capable bot facing a beatable opponent must now overwhelmingly
// prefer keeping its four pets over selling one for a throwaway apple.
func TestDecideShopKeepsHealthyBoard(t *testing.T) {
pet := func(id, name string, tier, power int, suit game.Suit) game.Card {
return game.Card{ID: id, Kind: game.KindPet, Name: name, Tier: tier, Power: power, Suit: suit}
}
v := &game.View{
Phase: game.PhaseShop,
Round: 3, // mid-game: future value still carries real weight
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: 4, DeckSize: 4, // coins spent: pass vs sell
Deck: []game.Card{
pet("p1", "Dog", 3, 3, game.SuitRed),
pet("p2", "Sheep", 3, 2, game.SuitBlue),
pet("p3", "Ant", 1, 2, game.SuitYellow),
pet("p4", "Cricket", 1, 1, game.SuitRed),
}},
{Seat: 1, PetCount: 4, DeckSize: 4},
},
}
// A comparable opponent, so battles are genuinely competitive — selling is
// a real temptation, not a hopeless-position tie-break.
mem := &Memory{}
mem.Opp.Seat = 1
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
}
bot := New(1.0) // a capable bot should almost never make this trade
passes, sells := 0, 0
const iters = 300
for range iters {
switch bot.decideShop(v, mem).Type {
case "pass":
passes++
case "sell":
sells++
}
}
// Keeping the board must dominate: pre-fix this scenario went the other way
// (selling outnumbered passing). The generous margin absorbs rollout noise.
if passes < 4*sells {
t.Errorf("bot sheds a healthy board: pass=%d sell=%d (want pass >> sell)", passes, sells)
}
}