Add bot to play against.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user