Add bot to play against.

This commit is contained in:
Greyson Parrelli
2026-07-23 15:58:12 -04:00
parent 3825dbacec
commit db1a6ef290
23 changed files with 1852 additions and 23 deletions
+204
View File
@@ -0,0 +1,204 @@
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")
}
}