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

261 lines
7.4 KiB
Go

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