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

254 lines
7.4 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 (
"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 "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)
}
}
}
// 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")
}
}