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:
+6
-7
@@ -67,13 +67,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if v.Turn == v.YouSeat && me.Coins > 0 {
|
||||
// During the shop, Ready means "passed": the turn keeps coming back
|
||||
// (even with no coins — selling and trading are free) until the bot
|
||||
// passes.
|
||||
if v.Turn == v.YouSeat && !me.Ready {
|
||||
return b.decideShop(v, mem)
|
||||
}
|
||||
case game.PhaseCleanup:
|
||||
if !me.Ready {
|
||||
return b.decideCleanup(v, mem)
|
||||
}
|
||||
case game.PhaseArrange:
|
||||
if !me.Ready {
|
||||
return b.decideArrange(v, mem)
|
||||
@@ -98,8 +97,8 @@ func Pending(v *game.View) bool {
|
||||
if v.Pending != nil {
|
||||
return v.Pending.PlayerID == me.ID
|
||||
}
|
||||
return v.Turn == v.YouSeat && me.Coins > 0
|
||||
case game.PhaseCleanup, game.PhaseArrange, game.PhaseBattle:
|
||||
return v.Turn == v.YouSeat && !me.Ready
|
||||
case game.PhaseArrange, game.PhaseBattle:
|
||||
return !me.Ready
|
||||
}
|
||||
return false
|
||||
|
||||
+10
-6
@@ -66,9 +66,6 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
|
||||
case "buy":
|
||||
return g.Buy(playerID, a.Row)
|
||||
case "sell":
|
||||
if g.Phase == game.PhaseCleanup {
|
||||
return g.CleanupSell(playerID, a.Cards)
|
||||
}
|
||||
return g.Sell(playerID, a.Cards)
|
||||
case "trade":
|
||||
return g.TradeStart(playerID, a.Cards)
|
||||
@@ -112,7 +109,8 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
|
||||
}
|
||||
obs()
|
||||
|
||||
// Whoever holds priority shops first; walk both players through buys.
|
||||
// Whoever holds priority shops first; walk both players through buys,
|
||||
// then have both pass to end the shop.
|
||||
first, second := g.Players[g.PrioritySeat], g.Players[1-g.PrioritySeat]
|
||||
for range 3 { // 3 coins each, alternating
|
||||
for _, p := range []*game.Player{first, second} {
|
||||
@@ -122,6 +120,12 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
|
||||
obs()
|
||||
}
|
||||
}
|
||||
for _, p := range []*game.Player{first, second} {
|
||||
if err := g.Pass(p.ID); err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
obs()
|
||||
}
|
||||
|
||||
// The model of B's deck must now match B's real deck card-for-card:
|
||||
// every buy was public (and buy effects like Otter's apple are printed
|
||||
@@ -129,8 +133,8 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
|
||||
assertModelMatches(t, mem, pb)
|
||||
|
||||
// Play out the round; the battle lineup resync must also match.
|
||||
for g.Phase == game.PhaseCleanup {
|
||||
t.Fatal("unexpected cleanup with 3 buys")
|
||||
if g.Phase != game.PhaseArrange {
|
||||
t.Fatalf("phase = %s, want arrange after both pass", g.Phase)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
ids := make([]string, len(p.Deck))
|
||||
|
||||
@@ -131,7 +131,7 @@ func Observe(v *game.View, m *Memory) {
|
||||
|
||||
// Reconcile with the public deck size. Skipped during the battle phase,
|
||||
// where the live deck still holds temporaries the model excludes.
|
||||
if v.Phase == game.PhaseShop || v.Phase == game.PhaseCleanup || v.Phase == game.PhaseArrange {
|
||||
if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange {
|
||||
size := v.Players[slices.IndexFunc(v.Players, func(p game.PlayerView) bool { return p.Seat == oppSeat })].DeckSize
|
||||
for len(m.Opp.Known)+len(m.Opp.Hidden) < size {
|
||||
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round})
|
||||
|
||||
+41
-58
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user