Add bot to play against.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user