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
+47 -23
View File
@@ -98,8 +98,8 @@ func TestSellConvertsToApples(t *testing.T) {
if err := g.Sell(p.ID, []string{plain.ID}); err != nil {
t.Fatal(err)
}
if p.Coins != CoinsPerRound-1 {
t.Fatalf("sell should cost 1 coin, coins=%d", p.Coins)
if p.Coins != CoinsPerRound {
t.Fatalf("selling should be free, coins=%d", p.Coins)
}
if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple {
t.Fatalf("sold pet should become an apple: %+v", p.Deck)
@@ -187,8 +187,8 @@ func TestTradeInThreeMatchingSuits(t *testing.T) {
if deck2[len(deck2)-1].ID != rejected.ID {
t.Fatal("rejected card should go to the bottom of the tier deck")
}
if p.Coins != CoinsPerRound-1 {
t.Fatalf("trade should cost 1 coin, coins=%d", p.Coins)
if p.Coins != CoinsPerRound {
t.Fatalf("trading should be free, coins=%d", p.Coins)
}
}
@@ -307,6 +307,7 @@ func TestSwanTripleRefreshesGold(t *testing.T) {
g, _, _ := testGame(t)
g.Round = tc.round
p := current(g)
p.Coins-- // a spent coin, so the refresh has something to restore
var ids []string
for range 3 {
s := g.realPet(t, "Swan")
@@ -327,7 +328,7 @@ func TestSwanTripleRefreshesGold(t *testing.T) {
func TestGiraffeBattlePrep(t *testing.T) {
g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe"))
spendAllCoins(t, g)
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("expected arrange, got %s", g.Phase)
}
@@ -370,8 +371,8 @@ func TestTradeBlockedOnFinalRound(t *testing.T) {
}
}
// spendAllCoins has both players pass until the shop ends.
func spendAllCoins(t *testing.T, g *Game) {
// passShop has both players pass until the shop ends.
func passShop(t *testing.T, g *Game) {
t.Helper()
for g.Phase == PhaseShop {
if err := g.Pass(current(g).ID); err != nil {
@@ -382,30 +383,52 @@ func spendAllCoins(t *testing.T, g *Game) {
func TestShopEndsIntoArrange(t *testing.T) {
g, _, _ := testGame(t)
spendAllCoins(t, g)
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("shop should end into arrange when no one is over the pet limit, got %s", g.Phase)
t.Fatalf("shop should end into arrange once both players pass, got %s", g.Phase)
}
}
func TestForcedDiscardOverPetLimit(t *testing.T) {
// Passing is final: a passed player's turn never comes back, and their coins
// are forfeit, while the other player keeps shopping.
func TestPassEndsShoppingForTheRound(t *testing.T) {
g, _, _ := testGame(t)
p, other := current(g), g.Players[(g.Turn+1)%2]
if err := g.Pass(p.ID); err != nil {
t.Fatal(err)
}
if p.Coins != 0 {
t.Fatalf("passing should forfeit remaining coins, got %d", p.Coins)
}
if g.Phase != PhaseShop || current(g).ID != other.ID {
t.Fatalf("shop should continue with the other player, phase=%s turn=%d", g.Phase, g.Turn)
}
// A free action by the remaining player must not hand the turn back.
junk := g.pet("Junk", 1)
other.Deck = append(other.Deck, junk)
if err := g.Sell(other.ID, []string{junk.ID}); err != nil {
t.Fatal(err)
}
if current(g).ID != other.ID {
t.Fatal("turn must stay with the only player still shopping")
}
}
func TestPassBlockedOverPetLimit(t *testing.T) {
g, p1, _ := testGame(t)
g.Turn = p1.Seat
for range MaxPets + 2 {
p1.Deck = append(p1.Deck, g.pet("Extra", 1))
}
spendAllCoins(t, g)
if g.Phase != PhaseCleanup {
t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase)
if err := g.Pass(p1.ID); err == nil {
t.Fatalf("passing with %d pets must be rejected", MaxPets+2)
}
// Wrong count rejected.
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil {
t.Fatal("must sell exactly the excess")
}
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
// Selling down (free) unblocks the pass; the discards become apples.
if err := g.Sell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
t.Fatal(err)
}
if p1.PetCount() != MaxPets {
t.Fatalf("expected %d pets after cleanup, got %d", MaxPets, p1.PetCount())
t.Fatalf("expected %d pets after selling down, got %d", MaxPets, p1.PetCount())
}
apples := 0
for _, c := range p1.Deck {
@@ -416,15 +439,16 @@ func TestForcedDiscardOverPetLimit(t *testing.T) {
if apples != 2 {
t.Fatalf("discarded pets should become apples, got %d", apples)
}
passShop(t, g)
if g.Phase != PhaseArrange {
t.Fatalf("cleanup should flow into arrange, got %s", g.Phase)
t.Fatalf("shop should flow into arrange, got %s", g.Phase)
}
}
func TestArrangeRejectsBadPermutation(t *testing.T) {
g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.pet("A", 1), g.pet("B", 2))
spendAllCoins(t, g)
passShop(t, g)
if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID}); err == nil {
t.Fatal("partial order should be rejected")
}
@@ -462,7 +486,7 @@ func TestFullGameFlow(t *testing.T) {
t.Fatalf("round %d shop dealt tier %d card", round, c.Tier)
}
}
spendAllCoins(t, g)
passShop(t, g)
for _, p := range g.Players {
if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil {
t.Fatal(err)
@@ -558,7 +582,7 @@ func TestDebugGrant(t *testing.T) {
if !Catalog()[0].IsPet() { // sanity on the shared catalog helper
t.Fatal("catalog should start with a pet")
}
// Not allowed outside shop/cleanup.
// Not allowed outside the shop.
g.Phase = PhaseBattle
if err := g.DebugGrant(p1.ID, "Ant"); err == nil {
t.Fatal("grant should be rejected outside the shop")