Files
super-auto-pets-board-game/internal/ai/shop.go
T

412 lines
13 KiB
Go

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
}