diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2f7d03b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# Super Auto Pets: The Board Game + +Go backend (pure rules engine in `internal/game`, WebSocket rooms in +`internal/server`, computer opponent in `internal/ai`) + React frontend in +`web/`. See README.md for the full layout and rules summary. + +- `mise run test` — Go tests · `mise run check` — go vet + frontend tsc + +## Keep the AI in sync with game behavior + +**Whenever game behavior changes — rules, cards, effects, phases, log +entries, or views — make sure the computer opponent accounts for it.** The +AI plays from the same information a human sees, so changes ripple into it +in specific ways: + +- **Battle rules**: `internal/ai` evaluates moves by running the real + resolver (`game.SimulateBattle`), so battle changes are picked up + automatically — but re-check the hand-written heuristics that summarize + battle wisdom: `leadScore`, `keepValue`, `deckValue` (eval.go) and the + food-placement strategies / synergy pet list (arrange.go). +- **New or changed cards/effects**: shop-time deck effects are mirrored in + `applyTemplateShopEffects` (shop.go); a new shop-time trigger or action + must be added there or the bot will misvalue it. +- **New public actions or log changes**: the bot tracks the opponent via + structured tags on public log entries (`LogBuy`, `LogSell`, `LogTrade`, + `LogTradePick`, spawn counts — see log.go). New public actions need tags + plus handling in `Observe` (memory.go). Tags must only ever duplicate + facts the entry's text already states publicly. +- **View changes**: the AI decides from `game.View` only — never hand it + the `Game`. If a field is added to the view, confirm it doesn't leak + hidden information (deck order, trade options, shop decks), because the + AI (and any client) would legitimately see it. +- **Phase/flow changes**: the server's bot driver (`internal/server/bots.go`) + must know when a bot owes an action (`ai.Pending`) and have a legal + fallback (`botFallback`) for any new phase or forced decision. + +After any such change, run `go test ./internal/ai/` — it plays complete +bot-vs-bot games and fails on any illegal or missing bot action. diff --git a/README.md b/README.md index da4c96c..a625b9d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Super Auto Pets: The Board Game — Online A web app for playing the Super Auto Pets board game remotely. 1v1 for now; -the engine is built to grow to more players. +the engine is built to grow to more players. Play a friend by room code, or +play solo against a computer opponent (easy / medium / hard). ## Stack @@ -94,8 +95,22 @@ All six tiers use the real card data: ``` cmd/server/ entrypoint internal/game/ rules engine (pure, fully tested) -internal/server/ HTTP + WebSocket rooms +internal/ai/ computer opponent (decides from a player View only) +internal/server/ HTTP + WebSocket rooms; drives bot turns internal/store/ SQLite persistence internal/env/ .env loading web/ React frontend ``` + +## The computer opponent + +Any seat can be a bot (`Player.IsBot`); humans and bots are interchangeable +to the engine, which is what will let future >2-player games mix them +freely. The AI in `internal/ai` never touches the `Game` — it decides from a +`game.View`, the same per-player state a human client is sent, plus a +persisted memory of public observations (battle lineups, the event log, shop +row changes). It cannot see your deck order, hidden trade picks, or the +shuffled shop decks. It scores candidate moves by Monte-Carlo battle +rollouts (`game.SimulateBattle`) against sampled guesses of your deck and +ordering, blended with a long-term deck-value heuristic; difficulty tunes a +softmax over the scored moves plus the rollout budget. diff --git a/internal/ai/ai.go b/internal/ai/ai.go new file mode 100644 index 0000000..1a7b846 --- /dev/null +++ b/internal/ai/ai.go @@ -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) +} diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go new file mode 100644 index 0000000..9867b74 --- /dev/null +++ b/internal/ai/ai_test.go @@ -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") + } +} diff --git a/internal/ai/arrange.go b/internal/ai/arrange.go new file mode 100644 index 0000000..5d0b031 --- /dev/null +++ b/internal/ai/arrange.go @@ -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), "|") +} diff --git a/internal/ai/eval.go b/internal/ai/eval.go new file mode 100644 index 0000000..0418ee4 --- /dev/null +++ b/internal/ai/eval.go @@ -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 +} diff --git a/internal/ai/memory.go b/internal/ai/memory.go new file mode 100644 index 0000000..9925bdd --- /dev/null +++ b/internal/ai/memory.go @@ -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, + } +} diff --git a/internal/ai/predict.go b/internal/ai/predict.go new file mode 100644 index 0000000..411575c --- /dev/null +++ b/internal/ai/predict.go @@ -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 +} diff --git a/internal/ai/shop.go b/internal/ai/shop.go new file mode 100644 index 0000000..19f02eb --- /dev/null +++ b/internal/ai/shop.go @@ -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} +} diff --git a/internal/game/battle.go b/internal/game/battle.go index 768aa76..c112869 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -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 { diff --git a/internal/game/game.go b/internal/game/game.go index 0133d12..381e5dd 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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) diff --git a/internal/game/log.go b/internal/game/log.go index 9d40ac8..3127a35 100644 --- a/internal/game/log.go +++ b/internal/game/log.go @@ -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). diff --git a/internal/game/sim.go b/internal/game/sim.go new file mode 100644 index 0000000..b02b7e9 --- /dev/null +++ b/internal/game/sim.go @@ -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] +} diff --git a/internal/game/view.go b/internal/game/view.go index f93b420..16269f5 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -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(), } diff --git a/internal/server/bot_e2e_test.go b/internal/server/bot_e2e_test.go new file mode 100644 index 0000000..1a1a690 --- /dev/null +++ b/internal/server/bot_e2e_test.go @@ -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) +} diff --git a/internal/server/bots.go b/internal/server/bots.go new file mode 100644 index 0000000..d9d26ae --- /dev/null +++ b/internal/server/bots.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 7aa947d..e99709f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) } diff --git a/internal/server/ws.go b/internal/server/ws.go index d67f7cf..a71cbaf 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -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. diff --git a/web/src/api.ts b/web/src/api.ts index d735100..1a3c185 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -13,8 +13,10 @@ async function post(path: string, body: unknown): Promise { return data as Session } -export function createGame(name: string): Promise { - return post('/api/games', { name }) +export type BotDifficulty = 'easy' | 'medium' | 'hard' + +export function createGame(name: string, bot?: BotDifficulty): Promise { + return post('/api/games', bot ? { name, bot } : { name }) } export function joinGame(code: string, name: string): Promise { diff --git a/web/src/components/Home.tsx b/web/src/components/Home.tsx index e142650..e7c412a 100644 --- a/web/src/components/Home.tsx +++ b/web/src/components/Home.tsx @@ -1,7 +1,14 @@ import { useState } from 'react' import { createGame, joinGame } from '../api' +import type { BotDifficulty } from '../api' import type { Session } from '../types' +const BOT_LEVELS: { value: BotDifficulty; label: string; blurb: string }[] = [ + { value: 'easy', label: '🐣 Easy', blurb: 'Learns you the ropes' }, + { value: 'medium', label: '🐺 Medium', blurb: 'Puts up a fight' }, + { value: 'hard', label: '🦁 Hard', blurb: 'Shows no mercy' }, +] + // Home is the create/join screen shown when there's no active session. export function Home({ onSession }: { onSession: (s: Session) => void }) { const [name, setName] = useState('') @@ -50,6 +57,24 @@ export function Home({ onSession }: { onSession: (s: Session) => void }) { Host a new game +
+ or challenge the computer +
+ +
+ {BOT_LEVELS.map((b) => ( + + ))} +
+
or join a friend
diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index a0b1cc7..42b7049 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -93,7 +93,13 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v key={p.id} className={`topbar-player ${p.seat === view.youSeat ? 'is-you' : ''}`} > - + {p.isBot ? ( + + 🤖 + + ) : ( + + )} {p.name} 🏆 {p.trophies} {(view.phase === 'shop' || view.phase === 'cleanup') && ( @@ -127,7 +133,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} - {opponents.some((p) => !p.connected) && view.phase !== 'lobby' && ( + {opponents.some((p) => !p.connected && !p.isBot) && view.phase !== 'lobby' && (
An opponent is disconnected…
)} {error &&
{error}
} diff --git a/web/src/styles.css b/web/src/styles.css index 487f9d6..751958a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -371,6 +371,17 @@ h3 { gap: 10px; } +/* The three computer-opponent difficulty buttons share the row evenly. */ +.home-bots { + display: flex; + gap: 10px; +} + +.home-bots .btn { + flex: 1; + white-space: nowrap; +} + .home-error { color: var(--red-soft); font-weight: 700; @@ -479,6 +490,12 @@ h3 { background: var(--red); } +/* Bots swap the connection dot for a little robot face. */ +.bot-dot { + font-size: 0.85rem; + line-height: 1; +} + /* The room code, stamped like a label on a card tray. */ .topbar-code { font-family: var(--font-display); diff --git a/web/src/types.ts b/web/src/types.ts index f0ba230..6ab5343 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -26,6 +26,7 @@ export interface PlayerView { trophies: number ready: boolean connected: boolean + isBot?: boolean deckSize: number petCount: number deck?: Card[]