309 lines
9.3 KiB
Go
309 lines
9.3 KiB
Go
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 "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")
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|