Improve AI to stop selling everything.
This commit is contained in:
@@ -306,3 +306,62 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDecideShopKeepsHealthyBoard guards against the slow-bleed bug: with its
|
||||||
|
// coins spent, the bot used to find that turning its worst pet into an apple
|
||||||
|
// scored marginally *better* than passing (the apple buffs a survivor for one
|
||||||
|
// battle; the permanent loss of a body barely dented the squashed future-value
|
||||||
|
// term). Repeated every shop turn, that shed the board down to a single pet and
|
||||||
|
// an all-apple hand — an automatic loss, since apples don't carry between
|
||||||
|
// rounds. A capable bot facing a beatable opponent must now overwhelmingly
|
||||||
|
// prefer keeping its four pets over selling one for a throwaway apple.
|
||||||
|
func TestDecideShopKeepsHealthyBoard(t *testing.T) {
|
||||||
|
pet := func(id, name string, tier, power int, suit game.Suit) game.Card {
|
||||||
|
return game.Card{ID: id, Kind: game.KindPet, Name: name, Tier: tier, Power: power, Suit: suit}
|
||||||
|
}
|
||||||
|
v := &game.View{
|
||||||
|
Phase: game.PhaseShop,
|
||||||
|
Round: 3, // mid-game: future value still carries real weight
|
||||||
|
MaxRounds: game.MaxRounds,
|
||||||
|
MaxPets: game.MaxPets,
|
||||||
|
Pack: game.DefaultPack,
|
||||||
|
YouSeat: 0,
|
||||||
|
Turn: 0,
|
||||||
|
PrioritySeat: 0,
|
||||||
|
DeckCounts: make([]int, game.MaxRounds+1),
|
||||||
|
Players: []game.PlayerView{
|
||||||
|
{Seat: 0, Coins: 0, PetCount: 4, DeckSize: 4, // coins spent: pass vs sell
|
||||||
|
Deck: []game.Card{
|
||||||
|
pet("p1", "Dog", 3, 3, game.SuitRed),
|
||||||
|
pet("p2", "Sheep", 3, 2, game.SuitBlue),
|
||||||
|
pet("p3", "Ant", 1, 2, game.SuitYellow),
|
||||||
|
pet("p4", "Cricket", 1, 1, game.SuitRed),
|
||||||
|
}},
|
||||||
|
{Seat: 1, PetCount: 4, DeckSize: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// A comparable opponent, so battles are genuinely competitive — selling is
|
||||||
|
// a real temptation, not a hopeless-position tie-break.
|
||||||
|
mem := &Memory{}
|
||||||
|
mem.Opp.Seat = 1
|
||||||
|
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
|
||||||
|
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
|
||||||
|
}
|
||||||
|
|
||||||
|
bot := New(1.0) // a capable bot should almost never make this trade
|
||||||
|
passes, sells := 0, 0
|
||||||
|
const iters = 300
|
||||||
|
for range iters {
|
||||||
|
switch bot.decideShop(v, mem).Type {
|
||||||
|
case "pass":
|
||||||
|
passes++
|
||||||
|
case "sell":
|
||||||
|
sells++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keeping the board must dominate: pre-fix this scenario went the other way
|
||||||
|
// (selling outnumbered passing). The generous margin absorbs rollout noise.
|
||||||
|
if passes < 4*sells {
|
||||||
|
t.Errorf("bot sheds a healthy board: pass=%d sell=%d (want pass >> sell)", passes, sells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import (
|
|||||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sellPetPenalty converts "persistent pet value destroyed by a sell" into a
|
||||||
|
// score penalty (see the sell-candidate logic in shop.go). A pet's keepValue
|
||||||
|
// is on the order of 1–5; multiplied by this weight and the future's share of
|
||||||
|
// the blend, one junk-pet sell drops a candidate by roughly a tenth of a
|
||||||
|
// win-probability point — enough to make keeping a body the default, while
|
||||||
|
// still letting a strongly positive rollout justify a genuine reshape.
|
||||||
|
const sellPetPenalty = 0.20
|
||||||
|
|
||||||
// winScore converts a simulated battle outcome to a utility for mySeat:
|
// winScore converts a simulated battle outcome to a utility for mySeat:
|
||||||
// win 1, draw 0.5 (nobody gains ground), loss 0, plus a small margin term.
|
// win 1, draw 0.5 (nobody gains ground), loss 0, plus a small margin term.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -242,11 +242,27 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
|||||||
}
|
}
|
||||||
return 0
|
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++ {
|
for k := 1; k <= min(3, len(sellable)); k++ {
|
||||||
ids := make([]string, 0, k)
|
ids := make([]string, 0, k)
|
||||||
nd := slices.Clone(deck)
|
nd := slices.Clone(deck)
|
||||||
|
petValueSold := 0.0
|
||||||
for _, s := range sellable[:k] {
|
for _, s := range sellable[:k] {
|
||||||
ids = append(ids, s.ID)
|
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 })
|
idx := slices.IndexFunc(nd, func(c game.Card) bool { return c.ID == s.ID })
|
||||||
nd = slices.Delete(nd, idx, idx+1)
|
nd = slices.Delete(nd, idx, idx+1)
|
||||||
nd = append(nd, cx.simApple())
|
nd = append(nd, cx.simApple())
|
||||||
@@ -260,6 +276,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
|||||||
cands = append(cands, candidate{
|
cands = append(cands, candidate{
|
||||||
act: &Action{Type: "sell", Cards: ids},
|
act: &Action{Type: "sell", Cards: ids},
|
||||||
decks: [][]game.Card{nd},
|
decks: [][]game.Card{nd},
|
||||||
|
bias: -sellPetPenalty * futureWeight * petValueSold,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +314,13 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
|||||||
base = slices.Delete(base, idx, idx+1)
|
base = slices.Delete(base, idx, idx+1)
|
||||||
base = cx.applyTemplateShopEffects(base, t, game.TriggerTriple)
|
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)
|
pool := cx.unseenPool(v.Round + 1)
|
||||||
if len(pool) == 0 {
|
if len(pool) == 0 {
|
||||||
pool = game.TierContentsForPack(v.Pack, v.Round+1)
|
pool = game.TierContentsForPack(v.Pack, v.Round+1)
|
||||||
|
|||||||
Reference in New Issue
Block a user