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, } } // 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}, }) } } // 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 }) 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.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 }