261 lines
7.4 KiB
Go
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}
|
|
}
|