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} }