package ai import ( "math/rand/v2" "slices" "github.com/greyson/super-auto-pets-board-game/internal/game" ) // deckHasPet reports whether a hypothetical deck still contains at least one // pet. A deck of only food (apples) can never field a fighter, so it is an // automatic loss — the bot must never voluntarily sell or trade its way there. func deckHasPet(deck []game.Card) bool { for _, c := range deck { if c.IsPet() { return true } } return false } // 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: n := max(e.Count, 1) switch e.Per { case game.PerShopFaintPets: n *= countShopFaintPets(cx.v.ShopRow) case game.PerBuysThisRound: // This buy will bump the counter, so count it (Blue-Ringed Octopus). n *= cx.me.BuysThisRound + 1 } for range n { deck = append(deck, cx.simApple()) } case game.ActionRevealForApples: // Cockatoo: the bot would reveal its highest-power other pet. best := 0 for _, d := range deck { if d.IsPet() && d.ID != c.ID && d.Power > best { best = d.Power } } for range best { deck = append(deck, cx.simApple()) } case game.ActionRevealTierForApples: // Quetzalcoatl: a fixed apple reward if a low-tier pet can be revealed. eligible := false for _, d := range deck { if d.IsPet() && d.ID != c.ID && (e.Cap <= 0 || d.Tier <= e.Cap) { eligible = true break } } if eligible { 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()) } case game.ActionApplesInPlay: // Golden pack (Hercules Beetle, sold): in-play apples aren't in the // deck, but they buff the front pet, so approximate them as deck // apples for scoring. MinRound is already gated above. for range max(e.Count, 1) { deck = append(deck, cx.simApple()) } case game.ActionBuyTopFree: // Golden pack (Stoat): grabs an unknown top-of-deck card; approximate // with a sampled card of the current tier. pool := cx.unseenPool(cx.v.Round) if len(pool) == 0 { pool = game.TierContentsForPacks(cx.v.Packs, cx.v.Round) } if len(pool) > 0 { rc := pool[rand.IntN(len(pool))] rc.ID = cx.nextSimID() deck = append(deck, rc) } case game.ActionSetAside: // Golden pack (Avocado): the just-bought token is set aside, not // kept in the deck being scored. if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 { deck = slices.Delete(deck, i, i+1) } case game.ActionUpgradeNextTier: // Unicorn pack (Water of Youth): discard this food and the lowest-value // pet, then gain a sampled top card of the next tier for free. nextTier := cx.v.Round + 1 if nextTier > game.MaxRounds { continue } if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 { deck = slices.Delete(deck, i, i+1) } worst, worstVal := -1, 0.0 for i, d := range deck { if !d.IsPet() { continue } if v := keepValue(d); worst < 0 || v < worstVal { worst, worstVal = i, v } } if worst < 0 { continue // no pet to sacrifice; the food is wasted } deck = slices.Delete(deck, worst, worst+1) pool := cx.unseenPool(nextTier) if len(pool) == 0 { pool = game.TierContentsForPacks(cx.v.Packs, nextTier) } if len(pool) > 0 { rc := pool[rand.IntN(len(pool))] rc.ID = cx.nextSimID() deck = append(deck, rc) deck = cx.applyTemplateShopEffects(deck, rc, game.TriggerBuy) } } } return deck } // countShopFaintPets counts pets in the shop row with a Faint effect (mirrors // the engine's Opossum payout). func countShopFaintPets(row []game.Card) int { n := 0 for _, c := range row { if c.ID == "" || !c.IsPet() { continue } for _, e := range c.Effects { if e.Trigger == game.TriggerFaint { n++ break } } } return n } func (cx *ctx) simApple() game.Card { return game.Card{ ID: cx.nextSimID(), Kind: game.KindFood, Name: "Apple", Food: game.FoodApple, Temporary: true, } } // previewSellDown applies the sell-down a pass would eventually force onto 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) previewSellDown(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. Only buying costs gold; selling and // trading are free, and passing (the only way to end the round's shopping) // is legal only at or under the pet limit. func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { cx := newCtx(v, mem) deck := cx.me.Deck var cands []candidate // Passing ends the bot's shopping for the round; it is the baseline every // other option must beat, nudged down while unspent coins remain because // spending them is usually right. Illegal over the pet limit — the sell // and trade candidates below always exist then, so the bot works its way // back under. if cx.me.PetCount <= v.MaxPets { bias := 0.0 if cx.me.Coins > 0 { bias = -0.02 } cands = append(cands, candidate{ act: &Action{Type: "pass"}, decks: [][]game.Card{slices.Clone(deck)}, bias: bias, }) } if cx.me.Coins > 0 { 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.previewSellDown(nd) cands = append(cands, candidate{ act: &Action{Type: "buy", Row: i}, decks: [][]game.Card{nd}, }) } } // Golden pack: buying by discarding an Avocado yields the same deck as a // coin buy, so it's only preferred when coins are scarce — a small negative // bias keeps the token in reserve otherwise. if cx.me.Avocados > 0 { 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.previewSellDown(nd) cands = append(cands, candidate{ act: &Action{Type: "buyAvocado", Row: i}, decks: [][]game.Card{nd}, bias: -0.05, }) } } // Sell candidates: the worst 1, 2, or 3 keepers. Selling is free and // takes any number of cards, so bulk-dumping junk before a battle is one // action. Temporary cards are excluded — selling an apple for an apple // does nothing. 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 }) // Selling a pet permanently trades a body for a temporary apple — the // apple's one-battle buff shows up in the rollout, but it vanishes next // round, so the persistent value of the pet is simply destroyed. The // future-value term barely registers this (normFuture squashes small deck // swings), which once let the bot treat "sell my worst pet" as free and // bleed its board down to a single pet over successive shop turns. This // explicit penalty prices the loss back in: it scales with the persistent // worth of the pets sold and with how much the future still matters // (1-alpha), so it bites hardest early and fades to nothing in the final // round, where selling for a decisive last battle is a legitimate play the // rollout can judge on its own. futureWeight := 1 - immediateWeight(v) for k := 1; k <= min(3, len(sellable)); k++ { ids := make([]string, 0, k) nd := slices.Clone(deck) petValueSold := 0.0 for _, s := range sellable[:k] { ids = append(ids, s.ID) if s.IsPet() { petValueSold += keepValue(s) } 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) } // Never dump the last pet: an all-food deck loses on sight, so this // candidate is off the table no matter how the rollouts score. if !deckHasPet(nd) { continue } cands = append(cands, candidate{ act: &Action{Type: "sell", Cards: ids}, decks: [][]game.Card{nd}, bias: -sellPetPenalty * futureWeight * petValueSold, }) } // 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) } // Never bet the whole board on the trade: the reward is the top of // the next tier's deck, which can be a food card (or nothing, if the // deck is spent), so trading away the last pets risks an all-food, // auto-losing deck. Mirror the sell guard and skip such a trade. if !deckHasPet(base) { continue } pool := cx.unseenPool(v.Round + 1) if len(pool) == 0 { pool = game.TierContentsForPacks(v.Packs, 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.previewSellDown(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.previewSellDown(nd) cands = append(cands, candidate{ act: &Action{Type: "tradeChoose", Pick: pick}, decks: [][]game.Card{nd}, }) } b.score(cx, cands) return b.pick(cands).act }