diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 9bff254..703ce41 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -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) + } +} diff --git a/internal/ai/eval.go b/internal/ai/eval.go index 1a25fd9..c3c1de3 100644 --- a/internal/ai/eval.go +++ b/internal/ai/eval.go @@ -8,6 +8,14 @@ import ( "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: // win 1, draw 0.5 (nobody gains ground), loss 0, plus a small margin term. // diff --git a/internal/ai/shop.go b/internal/ai/shop.go index b2046a9..45be44e 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -242,11 +242,27 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { } 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()) @@ -260,6 +276,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { cands = append(cands, candidate{ act: &Action{Type: "sell", Cards: ids}, 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 = 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)