Only buying costs gold; selling and trading (Triple) are free. Pass is now a final action, legal only at or under the pet limit: it forfeits remaining gold and ends that player's shopping for the round, with the shop closing once everyone has passed. That makes the separate cleanup phase unreachable (you sell down in-shop before passing), so it is removed. The client asks for confirmation before passing, and the bot knows buys are the only coin sink, when passing is legal, and that it must sell down before it can pass.
244 lines
7.1 KiB
Go
244 lines
7.1 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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|