Fix shop action costs and pass semantics.

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.
This commit is contained in:
Greyson Parrelli
2026-07-23 16:11:07 -04:00
parent db1a6ef290
commit 72e198a750
15 changed files with 273 additions and 297 deletions
+41 -58
View File
@@ -46,10 +46,11 @@ func (cx *ctx) simApple() game.Card {
}
}
// 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 {
// 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
@@ -92,37 +93,50 @@ func (b *Bot) score(cx *ctx, cands []candidate) {
}
// decideShop picks one shop action: buy a row card, sell some own cards,
// trade in a suit triple, or pass.
// 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 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
// 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
}
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},
act: &Action{Type: "pass"},
decks: [][]game.Card{slices.Clone(deck)},
bias: bias,
})
}
// 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.
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 {
@@ -195,7 +209,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
reward.ID = cx.nextSimID()
nd := append(slices.Clone(base), reward)
nd = cx.applyTemplateShopEffects(nd, reward, game.TriggerBuy)
nd = cx.previewCleanup(nd)
nd = cx.previewSellDown(nd)
decks = append(decks, nd)
}
cands = append(cands, candidate{
@@ -217,7 +231,7 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
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)
nd = cx.previewSellDown(nd)
cands = append(cands, candidate{
act: &Action{Type: "tradeChoose", Pick: pick},
decks: [][]game.Card{nd},
@@ -227,34 +241,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
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}
}