package ai import ( "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 { t.Helper() 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) } 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 "sell": if g.Phase == game.PhaseCleanup { return g.CleanupSell(playerID, a.Cards) } return g.Sell(playerID, a.Cards) case "trade": return g.TradeStart(playerID, a.Cards) case "tradeChoose": return g.TradeChoose(playerID, a.Pick) 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) } } } } // 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) mem := &Memory{} obs := func() { v := g.ViewFor(pa.ID) Observe(&v, mem) } obs() // Whoever holds priority shops first; walk both players through buys. 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() } } // 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. for g.Phase == game.PhaseCleanup { t.Fatal("unexpected cleanup with 3 buys") } 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") } }