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) } }