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
+173
View File
@@ -0,0 +1,173 @@
// Package ai implements a computer-controlled player.
//
// The bot is strictly information-hygienic: every decision is made from a
// game.View — the exact same state the server would send a human sitting in
// that seat — plus a Memory built purely from past public observations
// (battle lineups, the shared event log, and shop-row changes). The bot never
// touches the Game struct, so it cannot read the opponent's secret deck
// order, hidden trade picks, or upcoming shop cards even by accident.
//
// Decisions are made by generating candidate moves and scoring each one as a
// blend of two signals:
//
// - immediate: the estimated probability of winning the next battle,
// measured by Monte-Carlo rollouts (game.SimulateBattle) against sampled
// guesses of the opponent's deck and ordering;
// - future: a heuristic value of the resulting deck (power, tiers, suit
// synergy toward Triples) that only pays off in later rounds.
//
// The blend shifts toward "immediate" as the game nears its end and when the
// bot trails on trophies, which is what lets it deliberately take a weak
// round early to set up a stronger one later.
//
// Difficulty is a single level in [0, 1]: it sets the softmax temperature
// used to choose among scored candidates (a perfect bot always takes the top
// move; an easy bot often takes merely decent ones) and scales the rollout
// budget (an easy bot estimates win chances more noisily).
package ai
import (
"math"
"math/rand/v2"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// Action is one move the bot wants to make, mirroring the client protocol.
type Action struct {
Type string // "buy" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready"
Row int // buy
Cards []string // sell / trade
Pick int // tradeChoose
Order []string // arrange
}
// Bot is a computer player at a fixed difficulty level.
type Bot struct {
level float64
}
// New creates a bot with the given skill level in [0, 1].
func New(level float64) *Bot {
return &Bot{level: min(max(level, 0), 1)}
}
// Act computes the bot's next move from its view of the game, or nil when no
// input is owed. It does not modify the memory.
func (b *Bot) Act(v *game.View, mem *Memory) *Action {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return nil
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.Pending != nil {
if v.Pending.PlayerID == me.ID {
return b.decideTradeChoose(v, mem)
}
return nil
}
if v.Turn == v.YouSeat && me.Coins > 0 {
return b.decideShop(v, mem)
}
case game.PhaseCleanup:
if !me.Ready {
return b.decideCleanup(v, mem)
}
case game.PhaseArrange:
if !me.Ready {
return b.decideArrange(v, mem)
}
case game.PhaseBattle:
if !me.Ready {
return &Action{Type: "ready"}
}
}
return nil
}
// Pending reports whether the seat owes the game an action right now — the
// server uses it to decide when to schedule a bot move.
func Pending(v *game.View) bool {
if v.YouSeat < 0 || v.YouSeat >= len(v.Players) {
return false
}
me := &v.Players[v.YouSeat]
switch v.Phase {
case game.PhaseShop:
if v.Pending != nil {
return v.Pending.PlayerID == me.ID
}
return v.Turn == v.YouSeat && me.Coins > 0
case game.PhaseCleanup, game.PhaseArrange, game.PhaseBattle:
return !me.Ready
}
return false
}
// candidate is one scored move option. Most candidates map to a single
// hypothetical deck; a trade maps to several (one per sampled reward card)
// whose scores are averaged.
type candidate struct {
act *Action
decks [][]game.Card
bias float64 // small nudge applied on top of the evaluated score
score float64
}
// pick chooses among candidates with a softmax over their scores. The
// difficulty level sets the temperature: near 0 the bot always takes the
// best move; higher temperatures make it increasingly willing to take
// second-best (or worse) options.
func (b *Bot) pick(cands []candidate) candidate {
if len(cands) == 1 {
return cands[0]
}
temp := 0.02 + 0.30*(1-b.level)
best := math.Inf(-1)
for _, c := range cands {
best = max(best, c.score)
}
weights := make([]float64, len(cands))
total := 0.0
for i, c := range cands {
weights[i] = math.Exp((c.score - best) / temp)
total += weights[i]
}
r := rand.Float64() * total
for i, w := range weights {
r -= w
if r <= 0 {
return cands[i]
}
}
return cands[len(cands)-1]
}
// budget returns the rollout counts for this difficulty: how many opponent
// deck/order guesses to test against, and how many dice-randomized battle
// simulations to run per guess. Fewer samples means noisier estimates, which
// is itself part of what makes an easy bot easy.
func (b *Bot) budget() (oppSamples, simsPer int) {
oppSamples = 6 + int(b.level*8) // 6 .. 14
simsPer = 1 + int(b.level*2) // 1 .. 3
return
}
// immediateWeight is how much of a move's score comes from the next battle
// versus long-term deck value. Later rounds shift weight toward "win now"
// (round 6 is worth double and there is no later); trailing on trophies
// pushes the same way, while a comfortable lead frees the bot to invest.
func immediateWeight(v *game.View) float64 {
w := 0.40
if v.MaxRounds > 1 {
w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1)
}
me := v.Players[v.YouSeat]
for _, p := range v.Players {
if p.Seat != v.YouSeat {
w += 0.08 * float64(p.Trophies-me.Trophies)
}
}
return min(max(w, 0.25), 1)
}
+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")
}
}
+195
View File
@@ -0,0 +1,195 @@
package ai
import (
"slices"
"strings"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// decideArrange searches for the best secret battle ordering of the bot's
// deck. This is where "guess what the opponent will do" matters most: every
// candidate ordering is judged by simulated battles against a spread of
// sampled opponent decks and orderings, never against the opponent's real
// (hidden) choice.
//
// The search runs in two stages to stay cheap:
// 1. every permutation of the pets (≤ 5! = 120), each with a default food
// placement, gets a quick screening score;
// 2. the best few permutations are re-scored precisely, each trying several
// food-placement variants (front-loaded, on the strongest pet, spread,
// on an apple-synergy pet like Rooster or Leopard).
func (b *Bot) decideArrange(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
deck := cx.me.Deck
var pets, foods []game.Card
for _, c := range deck {
if c.IsPet() {
pets = append(pets, c)
} else {
foods = append(foods, c)
}
}
if len(pets) == 0 {
// Nothing can fight; any order loses identically.
return &Action{Type: "arrange", Order: cardIDs(deck)}
}
oppSamples, simsPer := b.budget()
oppDecks := cx.oppArrangements(oppSamples)
// Stage 1: screen every pet permutation with the default food placement
// against a subset of the opponent guesses.
perms := permutations(len(pets), 200)
screen := oppDecks[:min(4+int(b.level*4), len(oppDecks))]
type scored struct {
perm []int
score float64
}
ranked := make([]scored, 0, len(perms))
for _, perm := range perms {
arr := buildArrangement(pets, perm, foods, placeFront)
ranked = append(ranked, scored{perm, cx.winProb(arr, screen, 1)})
}
slices.SortStableFunc(ranked, func(a, b scored) int {
switch {
case a.score > b.score:
return -1
case a.score < b.score:
return 1
}
return 0
})
// Stage 2: refine the leaders with every food-placement variant and the
// full opponent sample set.
var cands []candidate
seen := map[string]bool{}
for _, r := range ranked[:min(5, len(ranked))] {
for _, place := range []foodPlacement{placeFront, placeStrongest, placeSpread, placeSynergy} {
arr := buildArrangement(pets, r.perm, foods, place)
key := fingerprint(arr)
if seen[key] {
continue
}
seen[key] = true
cands = append(cands, candidate{
act: &Action{Type: "arrange", Order: cardIDs(arr)},
score: cx.winProb(arr, oppDecks, simsPer),
})
}
}
return b.pick(cands).act
}
// foodPlacement decides which pet slot (index into the pet order) each food
// card sits in front of.
type foodPlacement func(pets []game.Card, foodIdx int, food game.Card) int
// placeFront stacks everything on the leading pet: it fights the most
// clashes, so buffs there see the most use.
func placeFront([]game.Card, int, game.Card) int { return 0 }
// placeStrongest feeds the biggest pet — apples on a heavy hitter compound,
// and perks protect the pet that fights longest.
func placeStrongest(pets []game.Card, _ int, _ game.Card) int {
best := 0
for i, p := range pets {
if p.Power > pets[best].Power {
best = i
}
}
return best
}
// placeSpread deals foods round-robin so one Skunk or Wolverine can't strip
// the whole stockpile at once.
func placeSpread(pets []game.Card, foodIdx int, _ game.Card) int {
return foodIdx % len(pets)
}
// placeSynergy targets pets whose abilities key off attached apples
// (Rooster's bees, Dodo's recycling, Leopard's per-power rocks, Peacock and
// Scorpion wanting to survive); falls back to the strongest pet.
func placeSynergy(pets []game.Card, foodIdx int, food game.Card) int {
for i, p := range pets {
switch p.Name {
case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion":
return i
}
}
return placeStrongest(pets, foodIdx, food)
}
// buildArrangement lays out the deck: foods assigned to a pet slot appear
// directly above that pet, and no food ever trails uselessly at the bottom.
// Perks assigned to the same pet keep only the last one applied, so extras
// are pushed to later pets.
func buildArrangement(pets []game.Card, perm []int, foods []game.Card, place foodPlacement) []game.Card {
ordered := make([]game.Card, len(perm))
for i, pi := range perm {
ordered[i] = pets[pi]
}
assign := make([][]game.Card, len(ordered))
perkUsed := make([]bool, len(ordered))
for fi, f := range foods {
at := place(ordered, fi, f)
if f.Perk {
// Slide duplicate perks onto the next unperked pet.
for at < len(ordered) && perkUsed[at] {
at++
}
if at >= len(ordered) {
at = len(ordered) - 1
}
perkUsed[at] = true
}
assign[at] = append(assign[at], f)
}
out := make([]game.Card, 0, len(pets)+len(foods))
for i, p := range ordered {
out = append(out, assign[i]...)
out = append(out, p)
}
return out
}
// permutations enumerates permutations of n indices, up to limit (5 pets is
// 120, so the limit only guards hypothetical future rule changes).
func permutations(n, limit int) [][]int {
idx := make([]int, n)
for i := range idx {
idx[i] = i
}
var out [][]int
var rec func(k int)
rec = func(k int) {
if len(out) >= limit {
return
}
if k == n {
out = append(out, slices.Clone(idx))
return
}
for i := k; i < n; i++ {
idx[k], idx[i] = idx[i], idx[k]
rec(k + 1)
idx[k], idx[i] = idx[i], idx[k]
}
}
rec(0)
return out
}
func cardIDs(cards []game.Card) []string {
ids := make([]string, len(cards))
for i, c := range cards {
ids[i] = c.ID
}
return ids
}
func fingerprint(cards []game.Card) string {
return strings.Join(cardIDs(cards), "|")
}
+201
View File
@@ -0,0 +1,201 @@
package ai
import (
"math"
"math/rand/v2"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// winScore converts a simulated battle outcome to a utility for mySeat:
// win 1, draw 0.5 (nobody gains ground), loss 0.
func winScore(res *game.BattleResult, mySeat int) float64 {
switch res.WinnerSeat {
case mySeat:
return 1
case -1:
return 0.5
default:
return 0
}
}
// winProb estimates the chance the arranged deck wins the upcoming battle by
// simulating it against every sampled opponent arrangement, simsPer times
// each (dice rerolled every time). All candidates in one decision share the
// same opponent samples, so comparisons between them are paired and fair.
func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int) float64 {
if len(oppDecks) == 0 || simsPer <= 0 {
return 0.5
}
total, n := 0.0, 0
for _, opp := range oppDecks {
for range simsPer {
var res *game.BattleResult
if cx.me.Seat == 0 {
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil)
} else {
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil)
}
total += winScore(res, cx.me.Seat)
n++
}
}
return total / float64(n)
}
// keepValue ranks a single card's worth to the bot's future: what it loses
// by selling or trading it away. Temporary cards (apples) are nearly free to
// lose — they vanish after the next battle anyway.
func keepValue(c game.Card) float64 {
if c.Temporary {
return 0.15
}
if c.IsFood() {
if c.Perk {
return 1.6
}
return 0.3
}
val := float64(c.Power)*0.55 + float64(c.Tier)*0.8
if len(c.Effects) > 0 {
val += 0.6
}
return val
}
// deckValue is the future-facing worth of a deck: card quality plus suit
// synergy (pairs and triples enable the Triple trade-in, the only path to
// higher-tier cards than the current round offers). Temporary cards count
// for nothing here — their value shows up in the battle rollouts instead.
func deckValue(deck []game.Card, round, maxRounds int) float64 {
total := 0.0
suits := map[game.Suit]int{}
for _, c := range deck {
if c.Temporary {
continue
}
total += keepValue(c)
if c.IsPet() {
suits[c.Suit]++
}
}
if round < maxRounds {
for _, k := range suits {
switch {
case k >= 3:
total += 1.5
case k == 2:
total += 0.6
}
}
}
return total
}
// normFuture squashes an unbounded deck value into (0, 1) so it can be
// blended with a win probability.
func normFuture(val float64) float64 {
return val / (val + 15)
}
// leadScore ranks pets for early battle positions: raw power fights longest,
// faint effects want to actually faint (early), play effects fire on entry
// wherever they are but are worth protecting slightly less.
func leadScore(c game.Card) float64 {
s := float64(c.Power)
for _, e := range c.Effects {
switch e.Trigger {
case game.TriggerFaint:
s += 1.5
case game.TriggerPlay:
s += 0.8
case game.TriggerHurt:
s += 0.5
}
}
return s
}
// heuristicOrder arranges a deck the way a reasonable player might: pets
// sorted by leadScore, apples front-loaded, perks spread across the
// strongest pets, never a food trailing at the bottom. temp adds Gumbel
// noise to every placement — 0 gives the deterministic "book" order, higher
// values give increasingly scrambled-but-plausible alternatives (used to
// model the range of orders an opponent might pick).
func heuristicOrder(deck []game.Card, temp float64) []game.Card {
var pets, apples, perks, otherFood []game.Card
for _, c := range deck {
switch {
case c.IsPet():
pets = append(pets, c)
case c.Perk:
perks = append(perks, c)
case c.Food == game.FoodApple:
apples = append(apples, c)
default:
otherFood = append(otherFood, c)
}
}
noisy := func(base float64) float64 {
if temp <= 0 {
return base
}
// Gumbel-perturbed scores turn a sort into a plausibility-weighted
// random ranking.
return base - temp*math.Log(-math.Log(rand.Float64()))
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := noisy(leadScore(a)), noisy(leadScore(b))
switch {
case av > bv:
return -1
case av < bv:
return 1
}
return 0
})
if len(pets) == 0 {
// No pets means an immediate loss; foods are wasted regardless.
return append(append(append(apples, perks...), otherFood...), pets...)
}
// Assign foods to pet indices, then interleave.
assign := make([][]game.Card, len(pets))
strongest := 0
for i, p := range pets {
if p.Power > pets[strongest].Power {
strongest = i
}
}
for _, a := range apples {
at := 0
if temp > 0 && rand.Float64() < 0.4 {
at = rand.IntN(len(pets))
}
assign[at] = append(assign[at], a)
}
// Perks one per pet, best pets first (a pet only keeps its last perk).
perkOrder := []int{strongest}
for i := range pets {
if i != strongest {
perkOrder = append(perkOrder, i)
}
}
for i, p := range perks {
at := perkOrder[min(i, len(perkOrder)-1)]
if temp > 0 && rand.Float64() < 0.3 {
at = rand.IntN(len(pets))
}
assign[at] = append(assign[at], p)
}
for i, f := range otherFood {
assign[i%len(pets)] = append(assign[i%len(pets)], f)
}
out := make([]game.Card, 0, len(deck))
for i, p := range pets {
out = append(out, assign[i]...)
out = append(out, p)
}
return out
}
+209
View File
@@ -0,0 +1,209 @@
package ai
import (
"encoding/json"
"fmt"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// Memory is the bot's private notebook: everything it has legitimately
// learned from public information, carried between turns (and, serialized
// into the game state, across server restarts). It is the bot's substitute
// for a human player's attention — nothing in here is unavailable to a human
// watching the same screen.
type Memory struct {
LastSeq int `json:"lastSeq"` // last event-log entry processed
LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested
PrevShopRow []game.Card `json:"prevShopRow"` // shop row at the previous observation
Opp OppModel `json:"opp"`
}
// OppModel is the bot's belief about one opponent's deck. Known holds cards
// it has actually seen there (battle lineups reveal entire decks each round;
// shop buys are public); Hidden counts cards it knows exist but has never
// seen — trade-in picks, whose tier is public but whose identity is not.
type OppModel struct {
Seat int `json:"seat"`
Known []game.Card `json:"known"`
Hidden []HiddenCard `json:"hidden,omitempty"`
}
// HiddenCard is a card the opponent holds that the bot has not seen. Name is
// set when the card was later named publicly (e.g. a trade pick revealed by
// its buy ability) — the suit still isn't known, but the stats are.
type HiddenCard struct {
Tier int `json:"tier"`
Name string `json:"name,omitempty"`
}
// LoadMemory decodes a bot's stored memory; a nil or corrupt blob yields a
// fresh one (the model self-heals from the next battle lineup anyway).
func LoadMemory(raw json.RawMessage) *Memory {
m := &Memory{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, m)
}
return m
}
// Marshal encodes the memory for storage on the bot's Player.
func (m *Memory) Marshal() json.RawMessage {
raw, err := json.Marshal(m)
if err != nil {
return nil
}
return raw
}
// Observe updates the memory from the bot's latest view. The server calls
// this on every state change, so consecutive observations are one action
// apart. It reads three public sources, in order:
//
// 1. new event-log entries, whose structured tags describe opponent shop
// actions (buys name the card, sells name what left, trades list the
// discarded trio, spawn entries count apples gained);
// 2. the latest battle's lineups, which reveal both decks in full and reset
// the model to ground truth every round (so any drift lasts one round);
// 3. the opponent's public deck size, as a reconciliation safety net.
func Observe(v *game.View, m *Memory) {
if v.YouSeat < 0 {
return
}
oppSeat := -1
for _, p := range v.Players {
if p.Seat != v.YouSeat {
oppSeat = p.Seat
break
}
}
if oppSeat < 0 {
return
}
m.Opp.Seat = oppSeat
for _, e := range v.Log {
if e.Seq <= m.LastSeq {
continue
}
m.LastSeq = e.Seq
if e.Seat != oppSeat {
continue
}
switch {
case e.Kind == game.LogBuy:
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
m.Opp.Known = append(m.Opp.Known, c)
} else if c, ok := templateByName(e.CardName); ok {
m.Opp.Known = append(m.Opp.Known, c)
}
case e.Kind == game.LogSell:
m.removeOppCard(e.Source, e.CardName)
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known)))
case e.Kind == game.LogTrade:
for _, id := range e.Cards {
m.removeOppCard(id, "")
}
case e.Kind == game.LogTradePick:
m.Opp.Hidden = append(m.Opp.Hidden,
HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName})
case e.Spawn == "apple" && e.Kind == "":
n := max(e.Count, 1)
for range n {
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known)))
}
}
}
// Battle lineups are ground truth: rebuild the model from the opponent's
// revealed deck, minus temporary cards (they expire with the battle).
if v.Battle != nil && v.Battle.Round > m.LastBattleRound && oppSeat < len(v.Battle.Lineups) {
m.LastBattleRound = v.Battle.Round
m.Opp.Known = m.Opp.Known[:0]
m.Opp.Hidden = nil
for _, c := range v.Battle.Lineups[oppSeat] {
if !c.Temporary {
m.Opp.Known = append(m.Opp.Known, c)
}
}
}
// Reconcile with the public deck size. Skipped during the battle phase,
// where the live deck still holds temporaries the model excludes.
if v.Phase == game.PhaseShop || v.Phase == game.PhaseCleanup || v.Phase == game.PhaseArrange {
size := v.Players[slices.IndexFunc(v.Players, func(p game.PlayerView) bool { return p.Seat == oppSeat })].DeckSize
for len(m.Opp.Known)+len(m.Opp.Hidden) < size {
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round})
}
for len(m.Opp.Known)+len(m.Opp.Hidden) > size {
if len(m.Opp.Hidden) > 0 {
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
} else {
m.Opp.Known = m.Opp.Known[:len(m.Opp.Known)-1]
}
}
}
m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...)
}
// removeOppCard drops one card from the model: by exact ID when we tracked
// it, by name as a fallback (model-minted apples have synthetic IDs), and
// failing both, one hidden card — something we didn't know they had left.
func (m *Memory) removeOppCard(id, name string) {
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
return
}
if name != "" {
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
return
}
}
if len(m.Opp.Hidden) > 0 {
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
}
}
func cardByID(cards []game.Card, id string) (game.Card, bool) {
if id == "" {
return game.Card{}, false
}
for _, c := range cards {
if c.ID == id {
return c, true
}
}
return game.Card{}, false
}
// templateByName mints a reference copy of a named card from the printed
// tier contents. The suit is whatever the first printed copy has — callers
// only rely on stats and effects.
func templateByName(name string) (game.Card, bool) {
if name == "" {
return game.Card{}, false
}
for tier := 1; tier <= game.MaxRounds; tier++ {
for _, c := range game.TierContents(tier) {
if c.Name == name {
return c, true
}
}
}
return game.Card{}, false
}
// memApple mints an apple for the opponent model. The ID is synthetic — it
// only needs to not collide with real card IDs.
func memApple(n int) game.Card {
return game.Card{
ID: fmt.Sprintf("mem-apple-%d", n),
Kind: game.KindFood,
Name: "Apple",
Food: game.FoodApple,
Temporary: true,
}
}
+109
View File
@@ -0,0 +1,109 @@
package ai
import (
"fmt"
"math/rand/v2"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// ctx is per-decision scratch state: the view, the memory, and caches shared
// by every candidate evaluated in the same decision.
type ctx struct {
v *game.View
m *Memory
me *game.PlayerView
oppSeat int
pools map[int][]game.Card // unseen cards per tier, for hidden-card guesses
simID int
}
func newCtx(v *game.View, m *Memory) *ctx {
cx := &ctx{v: v, m: m, me: &v.Players[v.YouSeat], oppSeat: m.Opp.Seat, pools: map[int][]game.Card{}}
if cx.oppSeat == cx.me.Seat || cx.oppSeat < 0 {
// Memory hasn't observed yet (shouldn't happen in practice).
for _, p := range v.Players {
if p.Seat != v.YouSeat {
cx.oppSeat = p.Seat
}
}
}
return cx
}
func (cx *ctx) nextSimID() string {
cx.simID++
return fmt.Sprintf("sim-%d", cx.simID)
}
// unseenPool lists the printed cards of a tier that the bot cannot account
// for anywhere it can see — its own deck, the opponent model, the shop row.
// Hidden opponent cards are drawn from this pool, so the bot's guesses
// respect card counting without peeking at the real decks.
func (cx *ctx) unseenPool(tier int) []game.Card {
if pool, ok := cx.pools[tier]; ok {
return pool
}
seen := map[string]int{}
note := func(c game.Card) {
if c.Tier == tier {
seen[c.Name]++
}
}
for _, c := range cx.me.Deck {
note(c)
}
for _, c := range cx.m.Opp.Known {
note(c)
}
for _, c := range cx.v.ShopRow {
note(c)
}
var pool []game.Card
for _, c := range game.TierContents(tier) {
if seen[c.Name] > 0 {
seen[c.Name]--
continue
}
pool = append(pool, c)
}
cx.pools[tier] = pool
return pool
}
// sampleOppDeck instantiates one concrete guess at the opponent's deck:
// known cards as-is, hidden cards drawn from the unseen pool of their tier
// (or their named template, when a pick was later revealed).
func (cx *ctx) sampleOppDeck() []game.Card {
deck := append([]game.Card(nil), cx.m.Opp.Known...)
for _, h := range cx.m.Opp.Hidden {
var c game.Card
if t, ok := templateByName(h.Name); ok {
c = t
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
c = pool[rand.IntN(len(pool))]
} else {
continue // tier exhausted and fully accounted for; nothing to guess
}
c.ID = cx.nextSimID()
deck = append(deck, c)
}
return deck
}
// oppArrangements produces n independent guesses of what the opponent will
// field: each is a sampled deck put in a plausible order by the same
// heuristic the bot itself uses, with enough noise that the bot prepares for
// a range of opponent plans rather than assuming one. The first guess is the
// noise-free "book" ordering.
func (cx *ctx) oppArrangements(n int) [][]game.Card {
out := make([][]game.Card, 0, n)
for i := range n {
temp := 0.8
if i == 0 {
temp = 0
}
out = append(out, heuristicOrder(cx.sampleOppDeck(), temp))
}
return out
}
+260
View File
@@ -0,0 +1,260 @@
package ai
import (
"math/rand/v2"
"slices"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// applyTemplateShopEffects mirrors the engine's shop-time triggers on a
// hypothetical deck: buying an Otter really does come with an apple, and the
// bot should value that. Only deck-changing effects matter here (coin
// refunds don't alter the deck being scored).
func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger game.EffectTrigger) []game.Card {
for _, e := range c.Effects {
if e.Trigger != trigger || cx.v.Round < e.MinRound {
continue
}
switch e.Action {
case game.ActionGainApple:
for range max(e.Count, 1) {
deck = append(deck, cx.simApple())
}
case game.ActionDoubleApples:
apples := 0
for _, dc := range deck {
if dc.Food == game.FoodApple {
apples++
}
}
for range apples {
deck = append(deck, cx.simApple())
}
}
}
return deck
}
func (cx *ctx) simApple() game.Card {
return game.Card{
ID: cx.nextSimID(),
Kind: game.KindFood,
Name: "Apple",
Food: game.FoodApple,
Temporary: true,
}
}
// previewCleanup applies the forced end-of-shop sale to a hypothetical deck:
// while over the pet limit, the lowest-value pet is sold for an apple. This
// lets the bot buy a sixth pet on purpose, knowing what it will cost.
func (cx *ctx) previewCleanup(deck []game.Card) []game.Card {
for {
pets := 0
worst, worstVal := -1, 0.0
for i, c := range deck {
if !c.IsPet() {
continue
}
pets++
if v := keepValue(c); worst < 0 || v < worstVal {
worst, worstVal = i, v
}
}
if pets <= cx.v.MaxPets || worst < 0 {
return deck
}
sold := deck[worst]
deck = slices.Delete(deck, worst, worst+1)
deck = append(deck, cx.simApple())
deck = cx.applyTemplateShopEffects(deck, sold, game.TriggerSell)
}
}
// score fills in every candidate's score: a weighted blend of the estimated
// next-battle win chance (deck arranged by the book ordering — the full
// ordering search happens later, at arrange time) and the deck's future
// value. All candidates face the same opponent guesses.
func (b *Bot) score(cx *ctx, cands []candidate) {
oppSamples, simsPer := b.budget()
oppDecks := cx.oppArrangements(oppSamples)
alpha := immediateWeight(cx.v)
for i := range cands {
total := 0.0
for _, deck := range cands[i].decks {
imm := cx.winProb(heuristicOrder(deck, 0), oppDecks, simsPer)
fut := normFuture(deckValue(deck, cx.v.Round, cx.v.MaxRounds))
total += alpha*imm + (1-alpha)*fut
}
cands[i].score = total/float64(len(cands[i].decks)) + cands[i].bias
}
}
// decideShop picks one shop action: buy a row card, sell some own cards,
// trade in a suit triple, or pass.
func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
deck := cx.me.Deck
var cands []candidate
// Passing forfeits the bot's remaining coins; it is the baseline every
// other option must beat, with a nudge because spending is usually right.
cands = append(cands, candidate{
act: &Action{Type: "pass"},
decks: [][]game.Card{slices.Clone(deck)},
bias: -0.02,
})
for i, c := range v.ShopRow {
if c.ID == "" {
continue
}
nd := append(slices.Clone(deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd)
cands = append(cands, candidate{
act: &Action{Type: "buy", Row: i},
decks: [][]game.Card{nd},
})
}
// Sell candidates: the worst 1, 2, or 3 keepers. One gold sells any
// number of cards, so bulk-dumping junk before a battle is one action.
// Temporary cards are excluded — selling an apple for an apple is a pure
// waste of gold.
sellable := slices.Clone(deck)
sellable = slices.DeleteFunc(sellable, func(c game.Card) bool { return c.Temporary })
slices.SortStableFunc(sellable, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
for k := 1; k <= min(3, len(sellable)); k++ {
ids := make([]string, 0, k)
nd := slices.Clone(deck)
for _, s := range sellable[:k] {
ids = append(ids, s.ID)
idx := slices.IndexFunc(nd, func(c game.Card) bool { return c.ID == s.ID })
nd = slices.Delete(nd, idx, idx+1)
nd = append(nd, cx.simApple())
nd = cx.applyTemplateShopEffects(nd, s, game.TriggerSell)
}
cands = append(cands, candidate{
act: &Action{Type: "sell", Cards: ids},
decks: [][]game.Card{nd},
})
}
// Trade candidates: for each suit with three or more pets, trade the
// three lowest-value ones. The reward card is unknown (top two of the
// next tier's deck), so each trade is scored across several sampled
// rewards.
if v.Round < v.MaxRounds && v.Round < len(v.DeckCounts) && v.DeckCounts[v.Round] >= 2 {
bySuit := map[game.Suit][]game.Card{}
for _, c := range deck {
if c.IsPet() && c.Suit != "" {
bySuit[c.Suit] = append(bySuit[c.Suit], c)
}
}
for _, pets := range bySuit {
if len(pets) < game.TradeInCount {
continue
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
trio := pets[:game.TradeInCount]
base := slices.Clone(deck)
ids := make([]string, 0, game.TradeInCount)
for _, t := range trio {
ids = append(ids, t.ID)
idx := slices.IndexFunc(base, func(c game.Card) bool { return c.ID == t.ID })
base = slices.Delete(base, idx, idx+1)
base = cx.applyTemplateShopEffects(base, t, game.TriggerTriple)
}
pool := cx.unseenPool(v.Round + 1)
if len(pool) == 0 {
pool = game.TierContents(v.Round + 1)
}
var decks [][]game.Card
for range 3 {
reward := pool[rand.IntN(len(pool))]
reward.ID = cx.nextSimID()
nd := append(slices.Clone(base), reward)
nd = cx.applyTemplateShopEffects(nd, reward, game.TriggerBuy)
nd = cx.previewCleanup(nd)
decks = append(decks, nd)
}
cands = append(cands, candidate{
act: &Action{Type: "trade", Cards: ids},
decks: decks,
})
}
}
b.score(cx, cands)
return b.pick(cands).act
}
// decideTradeChoose resolves the bot's own pending trade: score keeping
// either revealed card and pick.
func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
var cands []candidate
for pick, c := range v.Pending.Options {
nd := append(slices.Clone(cx.me.Deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd)
cands = append(cands, candidate{
act: &Action{Type: "tradeChoose", Pick: pick},
decks: [][]game.Card{nd},
})
}
b.score(cx, cands)
return b.pick(cands).act
}
// decideCleanup performs the forced sale down to the pet limit, dumping the
// lowest-value pets. This one is deterministic at every difficulty — even a
// weak player doesn't discard their best pet by accident.
func (b *Bot) decideCleanup(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem)
excess := cx.me.PetCount - v.MaxPets
if excess <= 0 {
return nil
}
pets := make([]game.Card, 0, cx.me.PetCount)
for _, c := range cx.me.Deck {
if c.IsPet() {
pets = append(pets, c)
}
}
slices.SortStableFunc(pets, func(a, b game.Card) int {
av, bv := keepValue(a), keepValue(b)
switch {
case av < bv:
return -1
case av > bv:
return 1
}
return 0
})
ids := make([]string, 0, excess)
for _, p := range pets[:excess] {
ids = append(ids, p.ID)
}
return &Action{Type: "sell", Cards: ids}
}
+2 -2
View File
@@ -874,10 +874,10 @@ func (g *Game) resolveBattle() {
// Tagged "result" so the client can hold it back until the replay finishes
// (the outcome is known now, but showing it early would spoil the battle).
if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: "result",
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
} else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: "result",
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)})
}
for _, p := range g.Players {
+36 -9
View File
@@ -3,6 +3,7 @@ package game
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
@@ -49,6 +50,13 @@ type Player struct {
// TripledThisRound records whether the player used the Triple (trade-in)
// action during the current round's shop (Bison's Battle Prep).
TripledThisRound bool `json:"tripledThisRound"`
// IsBot marks a computer-controlled seat. The engine treats bots exactly
// like humans; the server drives their actions. BotLevel is the bot's
// skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the
// engine and persisted with the game so knowledge survives restarts.
IsBot bool `json:"isBot,omitempty"`
BotLevel float64 `json:"botLevel,omitempty"`
BotMemory json.RawMessage `json:"botMemory,omitempty"`
}
// PetCount counts pet cards in the player's deck.
@@ -196,6 +204,19 @@ func (g *Game) AddPlayer(name string) (*Player, error) {
return p, nil
}
// AddBot seats a computer-controlled player. Bots count as connected from
// the start; the server is responsible for driving their actions.
func (g *Game) AddBot(name string, level float64) (*Player, error) {
p, err := g.AddPlayer(name)
if err != nil {
return nil, err
}
p.IsBot = true
p.BotLevel = min(max(level, 0), 1)
p.Connected = true
return p, nil
}
// PlayerByID returns the player, or nil.
func (g *Game) PlayerByID(id string) *Player {
for _, p := range g.Players {
@@ -278,7 +299,8 @@ func (g *Game) Buy(playerID string, rowIdx int) error {
p.Coins--
bought := g.ShopRow[rowIdx]
p.Deck = append(p.Deck, bought)
g.logf(p.Seat, "🛒", "%s bought %s %s.", p.Name, article(bought.Name), bought.Name)
g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: bought.ID, CardName: bought.Name,
Text: fmt.Sprintf("%s bought %s %s.", p.Name, article(bought.Name), bought.Name)})
g.ShopRow[rowIdx] = g.drawFromTier(g.Round)
g.applyShopTrigger(p, bought, TriggerBuy)
g.advanceShopTurn()
@@ -320,7 +342,7 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
for range n {
p.Deck = append(p.Deck, g.newApple())
}
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple",
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: n,
Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)})
case ActionRefreshGold:
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
@@ -336,7 +358,7 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
p.Deck = append(p.Deck, g.newApple())
}
if apples > 0 {
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple",
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: apples,
Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)})
}
}
@@ -363,7 +385,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error {
}
for _, c := range sold {
p.Deck = append(p.Deck, g.newApple())
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple",
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Kind: LogSell, Source: c.ID, CardName: c.Name, Spawn: "apple",
Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)})
g.applyShopTrigger(p, c, TriggerSell)
}
@@ -427,11 +449,14 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
// The discarded trio is public — everyone sees what was given up — even
// though the pet ultimately chosen stays secret (see TradeChoose).
names := make([]string, len(traded))
ids := make([]string, len(traded))
for i, c := range traded {
names[i] = c.Name
ids[i] = c.ID
}
g.logf(p.Seat, "🔄", "%s traded in %s (%s) for a tier %d pick.",
p.Name, strings.Join(names, ", "), suit, nextTier)
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTrade, Cards: ids,
Text: fmt.Sprintf("%s traded in %s (%s) for a tier %d pick.",
p.Name, strings.Join(names, ", "), suit, nextTier)})
g.Pending = &PendingTrade{
PlayerID: playerID,
Tier: nextTier,
@@ -463,10 +488,12 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
// can't see the deck. But a pet with a Buy ability performs it publicly, so
// we have to reveal that pet (its effect log names it anyway).
if hasBuyEffect(chosen) {
g.logf(p.Seat, "🔄", "%s's trade pick is %s %s — its buy ability triggers.",
p.Name, article(chosen.Name), chosen.Name)
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick, CardName: chosen.Name,
Text: fmt.Sprintf("%s's trade pick is %s %s — its buy ability triggers.",
p.Name, article(chosen.Name), chosen.Name)})
} else {
g.logf(p.Seat, "🔄", "%s keeps their trade pick hidden.", p.Name)
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick,
Text: fmt.Sprintf("%s keeps their trade pick hidden.", p.Name)})
}
// Pets obtained via the Triple action trigger their Buy effects.
g.applyShopTrigger(p, chosen, TriggerBuy)
+17 -1
View File
@@ -14,12 +14,28 @@ type LogEntry struct {
Phase Phase `json:"phase"`
Seat int `json:"seat"` // acting seat, or -1 when none
Icon string `json:"icon,omitempty"` // leading emoji
Kind string `json:"kind,omitempty"` // e.g. "result" (battle outcome)
Kind string `json:"kind,omitempty"` // structured tag; see constants below
Text string `json:"text"` // the sentence itself
Source string `json:"source,omitempty"` // card id that caused a spawn
Spawn string `json:"spawn,omitempty"` // "apple" | "bee" for spawn entries
// The fields below add machine-readable copies of facts the Text already
// states publicly, so observers (the AI player included) don't have to
// parse English. They must never carry information the text doesn't.
Count int `json:"count,omitempty"` // e.g. apples gained
CardName string `json:"cardName,omitempty"` // named card, when public
Cards []string `json:"cards,omitempty"` // card ids involved, when public
}
// Structured LogEntry.Kind tags. Only "result" affects the client; the rest
// exist so observers can follow the public action stream structurally.
const (
LogResult = "result" // battle outcome (client holds it until the replay ends)
LogBuy = "buy" // Seat bought Source/CardName from the shop row
LogSell = "sell" // Seat sold Source/CardName (it became an apple)
LogTrade = "trade" // Seat traded in Cards for a next-tier pick
LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed
)
// addLog appends an entry, stamping it with the next sequence number and the
// current round/phase. Callers set Seat/Icon/Text (and Source/Spawn when the
// entry represents something spawning off a card).
+35
View File
@@ -0,0 +1,35 @@
package game
// SimulateBattle resolves a hypothetical two-player battle between the given
// arranged decks (top of deck first) and returns the result. It runs on a
// scratch game, so it never touches real state — callers (notably the AI
// player) can roll out as many what-if battles as they like. Dice rolls are
// random unless rollDie is non-nil.
func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
g := &Game{
Round: round,
PrioritySeat: prioritySeat,
RollDie: rollDie,
// Cards minted during the simulation (apples, bees) get IDs far away
// from real ones, purely to avoid confusion when reading results.
NextCardID: 1_000_000,
Players: []*Player{
{Name: "A", Seat: 0, Deck: append([]Card(nil), deckA...)},
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
},
}
g.resolveBattle()
return g.Battle
}
// TierContents returns the full printed contents of a tier's shop deck —
// public information from the box. Cards carry placeholder IDs; they are
// reference data, not live instances.
func TierContents(tier int) []Card {
scratch := &Game{}
scratch.buildShopDecks()
if tier < 1 || tier > len(scratch.ShopDecks) {
return nil
}
return scratch.ShopDecks[tier-1]
}
+2
View File
@@ -10,6 +10,7 @@ type PlayerView struct {
Trophies int `json:"trophies"`
Ready bool `json:"ready"`
Connected bool `json:"connected"`
IsBot bool `json:"isBot,omitempty"`
DeckSize int `json:"deckSize"`
PetCount int `json:"petCount"`
Deck []Card `json:"deck,omitempty"` // self only
@@ -70,6 +71,7 @@ func (g *Game) ViewFor(playerID string) View {
Trophies: p.Trophies,
Ready: p.Ready,
Connected: p.Connected,
IsBot: p.IsBot,
DeckSize: len(p.Deck),
PetCount: p.PetCount(),
}
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
// TestE2EBotGame creates a vs-computer game over the API, plays the human's
// shop turns over the wire, and verifies the scheduled bot actually takes
// its own turns (spends coins) without any second client connected.
func TestE2EBotGame(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := New(st, "", false)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := http.Post(ts.URL+"/api/games", "application/json",
bytes.NewBufferString(`{"name":"Human","bot":"easy"}`))
if err != nil {
t.Fatal(err)
}
var join joinResponse
if err := json.NewDecoder(resp.Body).Decode(&join); err != nil {
t.Fatal(err)
}
resp.Body.Close()
wsBase := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.Dial(ctx,
wsBase+"/api/ws?game="+join.GameID+"&player="+join.PlayerID+"&token="+join.Token, nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close(websocket.StatusNormalClosure, "")
v := readState(t, ctx, ws)
if v.Phase != game.PhaseShop {
t.Fatalf("phase = %s, want shop (bot fills the lobby instantly)", v.Phase)
}
var bot *game.PlayerView
for i := range v.Players {
if v.Players[i].IsBot {
bot = &v.Players[i]
}
}
if bot == nil {
t.Fatal("no bot seat in the game")
}
// Play the human side: pass whenever it's our turn. The game can only
// reach the arrange phase if the bot spends its own three coins too.
deadline := time.Now().Add(25 * time.Second)
for v.Phase == game.PhaseShop && time.Now().Before(deadline) {
if v.Turn == v.YouSeat && v.Players[v.YouSeat].Coins > 0 && v.Pending == nil {
send(t, ctx, ws, map[string]any{"type": "pass"})
}
rctx, rcancel := context.WithTimeout(ctx, 10*time.Second)
v = readState(t, rctx, ws)
rcancel()
}
if v.Phase == game.PhaseShop {
t.Fatalf("shop never ended; bot coins=%d", v.Players[bot.Seat].Coins)
}
t.Logf("reached phase %s; bot played its shop turns", v.Phase)
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"log/slog"
"math/rand/v2"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/ai"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// botDifficulty maps the API's difficulty names to a skill level and a
// table name for the bot.
var botDifficulty = map[string]struct {
level float64
name string
}{
"easy": {0.25, "Robo Rookie"},
"medium": {0.60, "Robo Rival"},
"hard": {1.00, "Robo Ace"},
}
// commitLocked is the one path every game mutation goes through: bots update
// their memories from the new public state, the game is persisted, every
// client gets its view, and the next bot move (if any) is scheduled. Callers
// must hold r.mu.
func (s *Server) commitLocked(r *room) {
observeBotsLocked(r.game)
s.persist(r)
r.broadcastLocked()
s.scheduleBotsLocked(r)
}
// observeBotsLocked gives each bot a look at the current state through its
// own player view — the same information a human in that seat would see.
func observeBotsLocked(g *game.Game) {
for _, p := range g.Players {
if !p.IsBot {
continue
}
mem := ai.LoadMemory(p.BotMemory)
view := g.ViewFor(p.ID)
ai.Observe(&view, mem)
p.BotMemory = mem.Marshal()
}
}
// scheduleBotsLocked arms a delayed move for the first bot that owes the
// game an action. The delay is there purely for feel — instant replies make
// the opponent seem like a vending machine. Only one timer runs per room;
// each fired move re-schedules the next.
func (s *Server) scheduleBotsLocked(r *room) {
if r.botArmed {
return
}
for _, p := range r.game.Players {
if !p.IsBot {
continue
}
view := r.game.ViewFor(p.ID)
if !ai.Pending(&view) {
continue
}
r.botArmed = true
playerID := p.ID
time.AfterFunc(botDelay(r.game.Phase), func() { s.runBot(r, playerID) })
return
}
}
// botDelay picks a humanlike pause before a bot move.
func botDelay(phase game.Phase) time.Duration {
ms := func(base, jitter int) time.Duration {
return time.Duration(base+rand.IntN(jitter+1)) * time.Millisecond
}
switch phase {
case game.PhaseShop:
return ms(700, 900)
case game.PhaseCleanup:
return ms(900, 600)
case game.PhaseArrange:
return ms(1600, 1600)
default: // battle acknowledgement
return ms(500, 300)
}
}
// runBot fires one scheduled bot move. The state may have changed while the
// timer ran, so everything is revalidated under the lock.
func (s *Server) runBot(r *room, playerID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.botArmed = false
p := r.game.PlayerByID(playerID)
if p == nil || !p.IsBot {
return
}
view := r.game.ViewFor(playerID)
if !ai.Pending(&view) {
// Someone else moved the game on; check the other seats.
s.scheduleBotsLocked(r)
return
}
act := ai.New(p.BotLevel).Act(&view, ai.LoadMemory(p.BotMemory))
var err error
if act == nil {
err = game.ErrInvalidAction
} else {
err = applyBotAction(r.game, playerID, act)
}
if err != nil {
// A bot must never wedge the game: fall back to the simplest legal
// move for the phase.
slog.Warn("bot action failed; using fallback", "game", r.game.ID, "player", playerID, "err", err)
if err := botFallback(r.game, playerID); err != nil {
slog.Error("bot fallback failed", "game", r.game.ID, "player", playerID, "err", err)
return
}
}
s.commitLocked(r)
}
// applyBotAction maps a bot decision onto the engine, mirroring the client
// message dispatch in apply().
func applyBotAction(g *game.Game, playerID string, a *ai.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
}
// botFallback makes the trivially legal move for whatever the game is
// waiting on: pass the shop turn, take the first trade option, sell the
// first excess pets, submit the deck as-is, or acknowledge the battle.
func botFallback(g *game.Game, playerID string) error {
p := g.PlayerByID(playerID)
if p == nil {
return game.ErrInvalidAction
}
switch g.Phase {
case game.PhaseShop:
if g.Pending != nil && g.Pending.PlayerID == playerID {
return g.TradeChoose(playerID, 0)
}
return g.Pass(playerID)
case game.PhaseCleanup:
excess := p.PetCount() - game.MaxPets
ids := make([]string, 0, excess)
for _, c := range p.Deck {
if c.IsPet() && len(ids) < excess {
ids = append(ids, c.ID)
}
}
return g.CleanupSell(playerID, ids)
case game.PhaseArrange:
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
return g.SubmitOrder(playerID, ids)
case game.PhaseBattle:
return g.AcknowledgeBattle(playerID)
}
return game.ErrInvalidAction
}
+31 -3
View File
@@ -62,6 +62,9 @@ type room struct {
game *game.Game
conns map[*client]struct{}
debug bool // mirrors Server.debug, for broadcastLocked
// botArmed is set while a delayed bot move is scheduled, so only one
// timer exists per room at a time.
botArmed bool
}
// getRoom returns the room for a game ID, loading it from the store if it
@@ -78,6 +81,11 @@ func (s *Server) getRoom(gameID string) (*room, error) {
}
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.rooms[gameID] = r
// If the game was persisted mid-bot-turn (e.g. across a server restart),
// get the bot moving again.
r.mu.Lock()
s.scheduleBotsLocked(r)
r.mu.Unlock()
return r, nil
}
@@ -116,24 +124,45 @@ type joinResponse struct {
func (s *Server) handleCreate(w http.ResponseWriter, req *http.Request) {
var body struct {
Name string `json:"name"`
// Bot, when set, fills the other seat with a computer player:
// "easy" | "medium" | "hard".
Bot string `json:"bot"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON body")
return
}
var bot struct {
level float64
name string
}
if body.Bot != "" {
var ok bool
bot, ok = botDifficulty[body.Bot]
if !ok {
httpError(w, http.StatusBadRequest, "unknown bot difficulty")
return
}
}
g := game.New()
p, err := g.AddPlayer(strings.TrimSpace(body.Name))
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if body.Bot != "" {
if _, err := g.AddBot(bot.name, bot.level); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
}
r := &room{game: g, conns: make(map[*client]struct{}), debug: s.debug}
s.mu.Lock()
s.rooms[g.ID] = r
s.mu.Unlock()
r.mu.Lock()
s.persist(r)
s.commitLocked(r)
r.mu.Unlock()
writeJSON(w, joinResponse{GameID: g.ID, Code: g.Code, PlayerID: p.ID, Token: p.Token})
}
@@ -163,9 +192,8 @@ func (s *Server) handleJoin(w http.ResponseWriter, req *http.Request) {
httpError(w, http.StatusConflict, err.Error())
return
}
s.persist(r)
resp := joinResponse{GameID: r.game.ID, Code: r.game.Code, PlayerID: p.ID, Token: p.Token}
r.broadcastLocked()
s.commitLocked(r)
r.mu.Unlock()
writeJSON(w, resp)
}
+4 -2
View File
@@ -73,6 +73,9 @@ func (s *Server) handleWS(w http.ResponseWriter, req *http.Request) {
r.conns[c] = struct{}{}
p.Connected = true
r.broadcastLocked()
// Safety net: if a scheduled bot move was ever lost (crash between
// persist and timer), a player connecting re-arms it.
s.scheduleBotsLocked(r)
r.mu.Unlock()
defer func() {
@@ -147,8 +150,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
c.sendError(err.Error())
return
}
s.persist(r)
r.broadcastLocked()
s.commitLocked(r)
}
// broadcastLocked sends each connected client its own view of the game.