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:
+26
-70
@@ -28,8 +28,7 @@ type Phase string
|
||||
|
||||
const (
|
||||
PhaseLobby Phase = "lobby" // waiting for players
|
||||
PhaseShop Phase = "shop" // players take turns spending coins
|
||||
PhaseCleanup Phase = "cleanup" // forced discard down to MaxPets pets
|
||||
PhaseShop Phase = "shop" // players take turns acting until all pass
|
||||
PhaseArrange Phase = "arrange" // players order their decks for battle
|
||||
PhaseBattle Phase = "battle" // battle resolved; players review the log
|
||||
PhaseGameOver Phase = "gameover" // all rounds played
|
||||
@@ -45,7 +44,7 @@ type Player struct {
|
||||
Coins int `json:"coins"`
|
||||
Deck []Card `json:"deck"`
|
||||
Trophies int `json:"trophies"`
|
||||
Ready bool `json:"ready"` // arrange submitted / battle acknowledged
|
||||
Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged
|
||||
Connected bool `json:"connected"`
|
||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||
// action during the current round's shop (Bison's Battle Prep).
|
||||
@@ -280,19 +279,20 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) {
|
||||
if g.Pending != nil {
|
||||
return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction)
|
||||
}
|
||||
if p.Coins <= 0 {
|
||||
return nil, ErrNoCoins
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Buy spends one coin to take the card at rowIdx into the player's deck.
|
||||
// The slot refills from the current round's tier deck.
|
||||
// The slot refills from the current round's tier deck. Buying is the only
|
||||
// action that costs gold.
|
||||
func (g *Game) Buy(playerID string, rowIdx int) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Coins <= 0 {
|
||||
return ErrNoCoins
|
||||
}
|
||||
if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" {
|
||||
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
|
||||
}
|
||||
@@ -307,8 +307,8 @@ func (g *Game) Buy(playerID string, rowIdx int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sell spends one coin to sell any number (>=1) of the player's cards: each
|
||||
// becomes an apple, and Sell effects on the sold cards fire.
|
||||
// Sell converts any number (>=1) of the player's cards: each becomes an
|
||||
// apple, and Sell effects on the sold cards fire. Selling is free.
|
||||
func (g *Game) Sell(playerID string, cardIDs []string) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
@@ -320,7 +320,6 @@ func (g *Game) Sell(playerID string, cardIDs []string) error {
|
||||
if err := g.sellCards(p, cardIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Coins--
|
||||
g.advanceShopTurn()
|
||||
return nil
|
||||
}
|
||||
@@ -403,9 +402,9 @@ func hasBuyEffect(c Card) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TradeStart spends one coin and three same-suit pets from the player's deck
|
||||
// to reveal the top two cards of the next tier's deck. The player must then
|
||||
// call TradeChoose before anything else happens.
|
||||
// TradeStart trades three same-suit pets from the player's deck to reveal
|
||||
// the top two cards of the next tier's deck — a free action. The player must
|
||||
// then call TradeChoose before anything else happens.
|
||||
func (g *Game) TradeStart(playerID string, cardIDs []string) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
@@ -444,7 +443,6 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
|
||||
traded = append(traded, p.Deck[idx])
|
||||
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
||||
}
|
||||
p.Coins--
|
||||
p.TripledThisRound = true
|
||||
// The discarded trio is public — everyone sees what was given up — even
|
||||
// though the pet ultimately chosen stays secret (see TradeChoose).
|
||||
@@ -505,7 +503,7 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
|
||||
// free and off-turn — a testing aid gated behind the server's DEBUG flag, not
|
||||
// a normal action. No buy effects fire.
|
||||
func (g *Game) DebugGrant(playerID, name string) error {
|
||||
if g.Phase != PhaseShop && g.Phase != PhaseCleanup {
|
||||
if g.Phase != PhaseShop {
|
||||
return ErrWrongPhase
|
||||
}
|
||||
p := g.PlayerByID(playerID)
|
||||
@@ -520,81 +518,39 @@ func (g *Game) DebugGrant(playerID, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pass forfeits the player's remaining coins and ends their shopping.
|
||||
// Pass is a player's final shop action: it ends their shopping for the rest
|
||||
// of the round, forfeiting any remaining coins. It is only legal at or under
|
||||
// the pet limit — a player holding too many pets must sell down first.
|
||||
func (g *Game) Pass(playerID string) error {
|
||||
p, err := g.requireShopTurn(playerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.PetCount() > MaxPets {
|
||||
return fmt.Errorf("%w: sell down to %d pets before passing", ErrInvalidAction, MaxPets)
|
||||
}
|
||||
p.Coins = 0
|
||||
g.logf(p.Seat, "✋", "%s passed.", p.Name)
|
||||
p.Ready = true
|
||||
g.logf(p.Seat, "✋", "%s passed — done shopping this round.", p.Name)
|
||||
g.advanceShopTurn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// advanceShopTurn hands the turn to the next player who still has coins, or
|
||||
// moves the game onward when everyone is spent.
|
||||
// advanceShopTurn hands the turn to the next player still shopping, or moves
|
||||
// on to arranging once everyone has passed. Passing is the only way out of
|
||||
// the shop, and it requires being at the pet limit, so no cleanup step is
|
||||
// needed here.
|
||||
func (g *Game) advanceShopTurn() {
|
||||
for i := 1; i <= len(g.Players); i++ {
|
||||
seat := (g.Turn + i) % len(g.Players)
|
||||
if g.Players[seat].Coins > 0 {
|
||||
if !g.Players[seat].Ready {
|
||||
g.Turn = seat
|
||||
return
|
||||
}
|
||||
}
|
||||
g.endShop()
|
||||
}
|
||||
|
||||
// endShop moves to forced discard if anyone is over the pet limit, otherwise
|
||||
// straight to arranging.
|
||||
func (g *Game) endShop() {
|
||||
over := false
|
||||
for _, p := range g.Players {
|
||||
p.Ready = p.PetCount() <= MaxPets
|
||||
if !p.Ready {
|
||||
over = true
|
||||
}
|
||||
}
|
||||
if over {
|
||||
g.Phase = PhaseCleanup
|
||||
return
|
||||
}
|
||||
g.beginArrange()
|
||||
}
|
||||
|
||||
// CleanupSell performs the forced end-of-shop sale: the player must sell
|
||||
// exactly their excess pets (each becomes an apple; Sell effects fire).
|
||||
func (g *Game) CleanupSell(playerID string, cardIDs []string) error {
|
||||
if g.Phase != PhaseCleanup {
|
||||
return ErrWrongPhase
|
||||
}
|
||||
p := g.PlayerByID(playerID)
|
||||
if p == nil {
|
||||
return errors.New("unknown player")
|
||||
}
|
||||
excess := p.PetCount() - MaxPets
|
||||
if excess <= 0 {
|
||||
return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction)
|
||||
}
|
||||
if len(cardIDs) != excess {
|
||||
return fmt.Errorf("%w: sell exactly %d pets", ErrInvalidAction, excess)
|
||||
}
|
||||
for _, id := range cardIDs {
|
||||
idx := p.cardIndex(id)
|
||||
if idx < 0 || !p.Deck[idx].IsPet() {
|
||||
return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction)
|
||||
}
|
||||
}
|
||||
if err := g.sellCards(p, cardIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Ready = true
|
||||
if g.allReady() {
|
||||
g.beginArrange()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) allReady() bool {
|
||||
for _, p := range g.Players {
|
||||
if !p.Ready {
|
||||
|
||||
+47
-23
@@ -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")
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestBisonBattlePrepRequiresTriple(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
spendAllCoins(t, g)
|
||||
passShop(t, g)
|
||||
want := 0
|
||||
if tripled {
|
||||
want = 3
|
||||
|
||||
Reference in New Issue
Block a user