366 lines
11 KiB
Go
366 lines
11 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.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.TierContentsForPack(cx.v.Pack, 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)
|
|
}
|
|
}
|
|
}
|
|
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.TierContentsForPack(v.Pack, 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
|
|
}
|
|
|