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
+11 -9
View File
@@ -48,18 +48,20 @@ Set via environment or a `.env` file (see `.env.example`):
Six rounds, each with its own shop tier deck. Per round: Six rounds, each with its own shop tier deck. Per round:
1. **Shop** — each player has 3 gold and players alternate actions, 1 gold 1. **Shop** — each player has 3 gold and players alternate actions: **buy**
each: **buy** one of 4 face-up cards; **sell** any number of hand cards one of 4 face-up cards (1 gold — the only action that costs anything);
(each becomes an 🍎 apple, +1 power food, and Sell effects fire); or **sell** any number of hand cards for free (each becomes an 🍎 apple, +1
**trade in** 3 same-suit pets (the Triple action) to pick 1 of the top 2 power food, and Sell effects fire); or **trade in** 3 same-suit pets (the
cards of the next tier's deck — Triple effects fire on the traded cards Triple action, also free) to pick 1 of the top 2 cards of the next tier's
and the received pet's Buy effect fires. Passing forfeits remaining gold. deck — Triple effects fire on the traded cards and the received pet's Buy
2. **Cleanup** — anyone holding more than 5 pets must sell down to 5. effect fires. **Passing** is a final action, only legal at 5 pets or
3. **Arrange** — Battle Prep effects fire first (e.g. Giraffe hands out fewer: it forfeits remaining gold and ends that player's shopping for the
round. The shop closes once everyone has passed.
2. **Arrange** — Battle Prep effects fire first (e.g. Giraffe hands out
apples), then players secretly order their decks. Food cards apply to the apples), then players secretly order their decks. Food cards apply to the
next pet after them; trailing foods are wasted. A pet only benefits from next pet after them; trailing foods are wasted. A pet only benefits from
its last-applied **perk** (e.g. Honey, Garlic). its last-applied **perk** (e.g. Honey, Garlic).
4. **Battle** — automatic stack machine. Cards reveal off the top of each 3. **Battle** — automatic stack machine. Cards reveal off the top of each
deck until a pet is in play. Play effects fire on reveal (rocks roll a d6 deck until a pet is in play. Play effects fire on reveal (rocks roll a d6
with faces 0/0/1/1/2/2 and hit the opposing pet before the clash; Skunk with faces 0/0/1/1/2/2 and hit the opposing pet before the clash; Skunk
strips foods; Wolverine steals apples; Chili mills the enemy deck). The strips foods; Wolverine steals apples; Chili mills the enemy deck). The
+6 -7
View File
@@ -67,13 +67,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
} }
return nil 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) return b.decideShop(v, mem)
} }
case game.PhaseCleanup:
if !me.Ready {
return b.decideCleanup(v, mem)
}
case game.PhaseArrange: case game.PhaseArrange:
if !me.Ready { if !me.Ready {
return b.decideArrange(v, mem) return b.decideArrange(v, mem)
@@ -98,8 +97,8 @@ func Pending(v *game.View) bool {
if v.Pending != nil { if v.Pending != nil {
return v.Pending.PlayerID == me.ID return v.Pending.PlayerID == me.ID
} }
return v.Turn == v.YouSeat && me.Coins > 0 return v.Turn == v.YouSeat && !me.Ready
case game.PhaseCleanup, game.PhaseArrange, game.PhaseBattle: case game.PhaseArrange, game.PhaseBattle:
return !me.Ready return !me.Ready
} }
return false return false
+10 -6
View File
@@ -66,9 +66,6 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
case "buy": case "buy":
return g.Buy(playerID, a.Row) return g.Buy(playerID, a.Row)
case "sell": case "sell":
if g.Phase == game.PhaseCleanup {
return g.CleanupSell(playerID, a.Cards)
}
return g.Sell(playerID, a.Cards) return g.Sell(playerID, a.Cards)
case "trade": case "trade":
return g.TradeStart(playerID, a.Cards) return g.TradeStart(playerID, a.Cards)
@@ -112,7 +109,8 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
} }
obs() 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] first, second := g.Players[g.PrioritySeat], g.Players[1-g.PrioritySeat]
for range 3 { // 3 coins each, alternating for range 3 { // 3 coins each, alternating
for _, p := range []*game.Player{first, second} { for _, p := range []*game.Player{first, second} {
@@ -122,6 +120,12 @@ func TestObserveTracksOpponentDeck(t *testing.T) {
obs() 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: // 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 // 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) assertModelMatches(t, mem, pb)
// Play out the round; the battle lineup resync must also match. // Play out the round; the battle lineup resync must also match.
for g.Phase == game.PhaseCleanup { if g.Phase != game.PhaseArrange {
t.Fatal("unexpected cleanup with 3 buys") t.Fatalf("phase = %s, want arrange after both pass", g.Phase)
} }
for _, p := range g.Players { for _, p := range g.Players {
ids := make([]string, len(p.Deck)) ids := make([]string, len(p.Deck))
+1 -1
View File
@@ -131,7 +131,7 @@ func Observe(v *game.View, m *Memory) {
// Reconcile with the public deck size. Skipped during the battle phase, // Reconcile with the public deck size. Skipped during the battle phase,
// where the live deck still holds temporaries the model excludes. // 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 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 { for len(m.Opp.Known)+len(m.Opp.Hidden) < size {
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round}) m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round})
+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: // previewSellDown applies the sell-down a pass would eventually force onto a
// while over the pet limit, the lowest-value pet is sold for an apple. This // hypothetical deck: while over the pet limit, the lowest-value pet is sold
// lets the bot buy a sixth pet on purpose, knowing what it will cost. // for an apple. This lets the bot buy a sixth pet on purpose, knowing what
func (cx *ctx) previewCleanup(deck []game.Card) []game.Card { // it will cost.
func (cx *ctx) previewSellDown(deck []game.Card) []game.Card {
for { for {
pets := 0 pets := 0
worst, worstVal := -1, 0.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, // 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 { func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
cx := newCtx(v, mem) cx := newCtx(v, mem)
deck := cx.me.Deck deck := cx.me.Deck
var cands []candidate var cands []candidate
// Passing forfeits the bot's remaining coins; it is the baseline every // Passing ends the bot's shopping for the round; it is the baseline every
// other option must beat, with a nudge because spending is usually right. // other option must beat, nudged down while unspent coins remain because
cands = append(cands, candidate{ // spending them is usually right. Illegal over the pet limit — the sell
act: &Action{Type: "pass"}, // and trade candidates below always exist then, so the bot works its way
decks: [][]game.Card{slices.Clone(deck)}, // back under.
bias: -0.02, if cx.me.PetCount <= v.MaxPets {
}) bias := 0.0
if cx.me.Coins > 0 {
for i, c := range v.ShopRow { bias = -0.02
if c.ID == "" {
continue
} }
nd := append(slices.Clone(deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd)
cands = append(cands, candidate{ cands = append(cands, candidate{
act: &Action{Type: "buy", Row: i}, act: &Action{Type: "pass"},
decks: [][]game.Card{nd}, decks: [][]game.Card{slices.Clone(deck)},
bias: bias,
}) })
} }
// Sell candidates: the worst 1, 2, or 3 keepers. One gold sells any if cx.me.Coins > 0 {
// number of cards, so bulk-dumping junk before a battle is one action. for i, c := range v.ShopRow {
// Temporary cards are excluded — selling an apple for an apple is a pure if c.ID == "" {
// waste of gold. 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.Clone(deck)
sellable = slices.DeleteFunc(sellable, func(c game.Card) bool { return c.Temporary }) sellable = slices.DeleteFunc(sellable, func(c game.Card) bool { return c.Temporary })
slices.SortStableFunc(sellable, func(a, b game.Card) int { 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() reward.ID = cx.nextSimID()
nd := append(slices.Clone(base), reward) nd := append(slices.Clone(base), reward)
nd = cx.applyTemplateShopEffects(nd, reward, game.TriggerBuy) nd = cx.applyTemplateShopEffects(nd, reward, game.TriggerBuy)
nd = cx.previewCleanup(nd) nd = cx.previewSellDown(nd)
decks = append(decks, nd) decks = append(decks, nd)
} }
cands = append(cands, candidate{ 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 { for pick, c := range v.Pending.Options {
nd := append(slices.Clone(cx.me.Deck), c) nd := append(slices.Clone(cx.me.Deck), c)
nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy) nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy)
nd = cx.previewCleanup(nd) nd = cx.previewSellDown(nd)
cands = append(cands, candidate{ cands = append(cands, candidate{
act: &Action{Type: "tradeChoose", Pick: pick}, act: &Action{Type: "tradeChoose", Pick: pick},
decks: [][]game.Card{nd}, decks: [][]game.Card{nd},
@@ -227,34 +241,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
return b.pick(cands).act 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}
}
+26 -70
View File
@@ -28,8 +28,7 @@ type Phase string
const ( const (
PhaseLobby Phase = "lobby" // waiting for players PhaseLobby Phase = "lobby" // waiting for players
PhaseShop Phase = "shop" // players take turns spending coins PhaseShop Phase = "shop" // players take turns acting until all pass
PhaseCleanup Phase = "cleanup" // forced discard down to MaxPets pets
PhaseArrange Phase = "arrange" // players order their decks for battle PhaseArrange Phase = "arrange" // players order their decks for battle
PhaseBattle Phase = "battle" // battle resolved; players review the log PhaseBattle Phase = "battle" // battle resolved; players review the log
PhaseGameOver Phase = "gameover" // all rounds played PhaseGameOver Phase = "gameover" // all rounds played
@@ -45,7 +44,7 @@ type Player struct {
Coins int `json:"coins"` Coins int `json:"coins"`
Deck []Card `json:"deck"` Deck []Card `json:"deck"`
Trophies int `json:"trophies"` 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"` Connected bool `json:"connected"`
// TripledThisRound records whether the player used the Triple (trade-in) // TripledThisRound records whether the player used the Triple (trade-in)
// action during the current round's shop (Bison's Battle Prep). // 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 { if g.Pending != nil {
return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction) return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction)
} }
if p.Coins <= 0 {
return nil, ErrNoCoins
}
return p, nil return p, nil
} }
// Buy spends one coin to take the card at rowIdx into the player's deck. // 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 { func (g *Game) Buy(playerID string, rowIdx int) error {
p, err := g.requireShopTurn(playerID) p, err := g.requireShopTurn(playerID)
if err != nil { if err != nil {
return err return err
} }
if p.Coins <= 0 {
return ErrNoCoins
}
if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" { if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" {
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction) 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 return nil
} }
// Sell spends one coin to sell any number (>=1) of the player's cards: each // Sell converts any number (>=1) of the player's cards: each becomes an
// becomes an apple, and Sell effects on the sold cards fire. // apple, and Sell effects on the sold cards fire. Selling is free.
func (g *Game) Sell(playerID string, cardIDs []string) error { func (g *Game) Sell(playerID string, cardIDs []string) error {
p, err := g.requireShopTurn(playerID) p, err := g.requireShopTurn(playerID)
if err != nil { if err != nil {
@@ -320,7 +320,6 @@ func (g *Game) Sell(playerID string, cardIDs []string) error {
if err := g.sellCards(p, cardIDs); err != nil { if err := g.sellCards(p, cardIDs); err != nil {
return err return err
} }
p.Coins--
g.advanceShopTurn() g.advanceShopTurn()
return nil return nil
} }
@@ -403,9 +402,9 @@ func hasBuyEffect(c Card) bool {
return false return false
} }
// TradeStart spends one coin and three same-suit pets from the player's deck // TradeStart trades three same-suit pets from the player's deck to reveal
// to reveal the top two cards of the next tier's deck. The player must then // the top two cards of the next tier's deck — a free action. The player must
// call TradeChoose before anything else happens. // then call TradeChoose before anything else happens.
func (g *Game) TradeStart(playerID string, cardIDs []string) error { func (g *Game) TradeStart(playerID string, cardIDs []string) error {
p, err := g.requireShopTurn(playerID) p, err := g.requireShopTurn(playerID)
if err != nil { if err != nil {
@@ -444,7 +443,6 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
traded = append(traded, p.Deck[idx]) traded = append(traded, p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1) p.Deck = slices.Delete(p.Deck, idx, idx+1)
} }
p.Coins--
p.TripledThisRound = true p.TripledThisRound = true
// The discarded trio is public — everyone sees what was given up — even // The discarded trio is public — everyone sees what was given up — even
// though the pet ultimately chosen stays secret (see TradeChoose). // 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 // free and off-turn — a testing aid gated behind the server's DEBUG flag, not
// a normal action. No buy effects fire. // a normal action. No buy effects fire.
func (g *Game) DebugGrant(playerID, name string) error { func (g *Game) DebugGrant(playerID, name string) error {
if g.Phase != PhaseShop && g.Phase != PhaseCleanup { if g.Phase != PhaseShop {
return ErrWrongPhase return ErrWrongPhase
} }
p := g.PlayerByID(playerID) p := g.PlayerByID(playerID)
@@ -520,81 +518,39 @@ func (g *Game) DebugGrant(playerID, name string) error {
return nil 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 { func (g *Game) Pass(playerID string) error {
p, err := g.requireShopTurn(playerID) p, err := g.requireShopTurn(playerID)
if err != nil { if err != nil {
return err return err
} }
if p.PetCount() > MaxPets {
return fmt.Errorf("%w: sell down to %d pets before passing", ErrInvalidAction, MaxPets)
}
p.Coins = 0 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() g.advanceShopTurn()
return nil return nil
} }
// advanceShopTurn hands the turn to the next player who still has coins, or // advanceShopTurn hands the turn to the next player still shopping, or moves
// moves the game onward when everyone is spent. // 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() { func (g *Game) advanceShopTurn() {
for i := 1; i <= len(g.Players); i++ { for i := 1; i <= len(g.Players); i++ {
seat := (g.Turn + i) % len(g.Players) seat := (g.Turn + i) % len(g.Players)
if g.Players[seat].Coins > 0 { if !g.Players[seat].Ready {
g.Turn = seat g.Turn = seat
return 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() 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 { func (g *Game) allReady() bool {
for _, p := range g.Players { for _, p := range g.Players {
if !p.Ready { if !p.Ready {
+47 -23
View File
@@ -98,8 +98,8 @@ func TestSellConvertsToApples(t *testing.T) {
if err := g.Sell(p.ID, []string{plain.ID}); err != nil { if err := g.Sell(p.ID, []string{plain.ID}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p.Coins != CoinsPerRound-1 { if p.Coins != CoinsPerRound {
t.Fatalf("sell should cost 1 coin, coins=%d", p.Coins) t.Fatalf("selling should be free, coins=%d", p.Coins)
} }
if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple { if p.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple {
t.Fatalf("sold pet should become an apple: %+v", p.Deck) 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 { if deck2[len(deck2)-1].ID != rejected.ID {
t.Fatal("rejected card should go to the bottom of the tier deck") t.Fatal("rejected card should go to the bottom of the tier deck")
} }
if p.Coins != CoinsPerRound-1 { if p.Coins != CoinsPerRound {
t.Fatalf("trade should cost 1 coin, coins=%d", p.Coins) t.Fatalf("trading should be free, coins=%d", p.Coins)
} }
} }
@@ -307,6 +307,7 @@ func TestSwanTripleRefreshesGold(t *testing.T) {
g, _, _ := testGame(t) g, _, _ := testGame(t)
g.Round = tc.round g.Round = tc.round
p := current(g) p := current(g)
p.Coins-- // a spent coin, so the refresh has something to restore
var ids []string var ids []string
for range 3 { for range 3 {
s := g.realPet(t, "Swan") s := g.realPet(t, "Swan")
@@ -327,7 +328,7 @@ func TestSwanTripleRefreshesGold(t *testing.T) {
func TestGiraffeBattlePrep(t *testing.T) { func TestGiraffeBattlePrep(t *testing.T) {
g, p1, _ := testGame(t) g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe")) p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe"))
spendAllCoins(t, g) passShop(t, g)
if g.Phase != PhaseArrange { if g.Phase != PhaseArrange {
t.Fatalf("expected arrange, got %s", g.Phase) 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. // passShop has both players pass until the shop ends.
func spendAllCoins(t *testing.T, g *Game) { func passShop(t *testing.T, g *Game) {
t.Helper() t.Helper()
for g.Phase == PhaseShop { for g.Phase == PhaseShop {
if err := g.Pass(current(g).ID); err != nil { 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) { func TestShopEndsIntoArrange(t *testing.T) {
g, _, _ := testGame(t) g, _, _ := testGame(t)
spendAllCoins(t, g) passShop(t, g)
if g.Phase != PhaseArrange { 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, p1, _ := testGame(t)
g.Turn = p1.Seat
for range MaxPets + 2 { for range MaxPets + 2 {
p1.Deck = append(p1.Deck, g.pet("Extra", 1)) p1.Deck = append(p1.Deck, g.pet("Extra", 1))
} }
spendAllCoins(t, g) if err := g.Pass(p1.ID); err == nil {
if g.Phase != PhaseCleanup { t.Fatalf("passing with %d pets must be rejected", MaxPets+2)
t.Fatalf("player with %d pets must be forced to discard, got phase %s", MaxPets+2, g.Phase)
} }
// Wrong count rejected. // Selling down (free) unblocks the pass; the discards become apples.
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:1]); err == nil { if err := g.Sell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
t.Fatal("must sell exactly the excess")
}
if err := g.CleanupSell(p1.ID, deckIDs(p1, Card.IsPet)[:2]); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p1.PetCount() != MaxPets { 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 apples := 0
for _, c := range p1.Deck { for _, c := range p1.Deck {
@@ -416,15 +439,16 @@ func TestForcedDiscardOverPetLimit(t *testing.T) {
if apples != 2 { if apples != 2 {
t.Fatalf("discarded pets should become apples, got %d", apples) t.Fatalf("discarded pets should become apples, got %d", apples)
} }
passShop(t, g)
if g.Phase != PhaseArrange { 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) { func TestArrangeRejectsBadPermutation(t *testing.T) {
g, p1, _ := testGame(t) g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.pet("A", 1), g.pet("B", 2)) 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 { if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID}); err == nil {
t.Fatal("partial order should be rejected") 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) t.Fatalf("round %d shop dealt tier %d card", round, c.Tier)
} }
} }
spendAllCoins(t, g) passShop(t, g)
for _, p := range g.Players { for _, p := range g.Players {
if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil { if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -558,7 +582,7 @@ func TestDebugGrant(t *testing.T) {
if !Catalog()[0].IsPet() { // sanity on the shared catalog helper if !Catalog()[0].IsPet() { // sanity on the shared catalog helper
t.Fatal("catalog should start with a pet") t.Fatal("catalog should start with a pet")
} }
// Not allowed outside shop/cleanup. // Not allowed outside the shop.
g.Phase = PhaseBattle g.Phase = PhaseBattle
if err := g.DebugGrant(p1.ID, "Ant"); err == nil { if err := g.DebugGrant(p1.ID, "Ant"); err == nil {
t.Fatal("grant should be rejected outside the shop") t.Fatal("grant should be rejected outside the shop")
+1 -1
View File
@@ -130,7 +130,7 @@ func TestBisonBattlePrepRequiresTriple(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
spendAllCoins(t, g) passShop(t, g)
want := 0 want := 0
if tripled { if tripled {
want = 3 want = 3
+4 -3
View File
@@ -65,11 +65,12 @@ func TestE2EBotGame(t *testing.T) {
t.Fatal("no bot seat in the game") t.Fatal("no bot seat in the game")
} }
// Play the human side: pass whenever it's our turn. The game can only // Play the human side: pass on our first turn (passing is final, so one
// reach the arrange phase if the bot spends its own three coins too. // is all we get). The game can only reach the arrange phase if the bot
// shops and passes on its own too.
deadline := time.Now().Add(25 * time.Second) deadline := time.Now().Add(25 * time.Second)
for v.Phase == game.PhaseShop && time.Now().Before(deadline) { for v.Phase == game.PhaseShop && time.Now().Before(deadline) {
if v.Turn == v.YouSeat && v.Players[v.YouSeat].Coins > 0 && v.Pending == nil { if v.Turn == v.YouSeat && !v.Players[v.YouSeat].Ready && v.Pending == nil {
send(t, ctx, ws, map[string]any{"type": "pass"}) send(t, ctx, ws, map[string]any{"type": "pass"})
} }
rctx, rcancel := context.WithTimeout(ctx, 10*time.Second) rctx, rcancel := context.WithTimeout(ctx, 10*time.Second)
+11 -15
View File
@@ -76,8 +76,6 @@ func botDelay(phase game.Phase) time.Duration {
switch phase { switch phase {
case game.PhaseShop: case game.PhaseShop:
return ms(700, 900) return ms(700, 900)
case game.PhaseCleanup:
return ms(900, 600)
case game.PhaseArrange: case game.PhaseArrange:
return ms(1600, 1600) return ms(1600, 1600)
default: // battle acknowledgement default: // battle acknowledgement
@@ -127,9 +125,6 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
case "buy": case "buy":
return g.Buy(playerID, a.Row) return g.Buy(playerID, a.Row)
case "sell": case "sell":
if g.Phase == game.PhaseCleanup {
return g.CleanupSell(playerID, a.Cards)
}
return g.Sell(playerID, a.Cards) return g.Sell(playerID, a.Cards)
case "trade": case "trade":
return g.TradeStart(playerID, a.Cards) return g.TradeStart(playerID, a.Cards)
@@ -146,8 +141,9 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
} }
// botFallback makes the trivially legal move for whatever the game is // botFallback makes the trivially legal move for whatever the game is
// waiting on: pass the shop turn, take the first trade option, sell the // waiting on: pass the shop turn (selling down to the pet limit first if
// first excess pets, submit the deck as-is, or acknowledge the battle. // passing would be refused), take the first trade option, submit the deck
// as-is, or acknowledge the battle.
func botFallback(g *game.Game, playerID string) error { func botFallback(g *game.Game, playerID string) error {
p := g.PlayerByID(playerID) p := g.PlayerByID(playerID)
if p == nil { if p == nil {
@@ -158,16 +154,16 @@ func botFallback(g *game.Game, playerID string) error {
if g.Pending != nil && g.Pending.PlayerID == playerID { if g.Pending != nil && g.Pending.PlayerID == playerID {
return g.TradeChoose(playerID, 0) return g.TradeChoose(playerID, 0)
} }
return g.Pass(playerID) if excess := p.PetCount() - game.MaxPets; excess > 0 {
case game.PhaseCleanup: ids := make([]string, 0, excess)
excess := p.PetCount() - game.MaxPets for _, c := range p.Deck {
ids := make([]string, 0, excess) if c.IsPet() && len(ids) < excess {
for _, c := range p.Deck { ids = append(ids, c.ID)
if c.IsPet() && len(ids) < excess { }
ids = append(ids, c.ID)
} }
return g.Sell(playerID, ids)
} }
return g.CleanupSell(playerID, ids) return g.Pass(playerID)
case game.PhaseArrange: case game.PhaseArrange:
ids := make([]string, len(p.Deck)) ids := make([]string, len(p.Deck))
for i, c := range p.Deck { for i, c := range p.Deck {
+1 -5
View File
@@ -122,11 +122,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
case "buy": case "buy":
err = g.Buy(c.playerID, msg.Row) err = g.Buy(c.playerID, msg.Row)
case "sell": case "sell":
if g.Phase == game.PhaseCleanup { err = g.Sell(c.playerID, msg.Cards)
err = g.CleanupSell(c.playerID, msg.Cards)
} else {
err = g.Sell(c.playerID, msg.Cards)
}
case "trade": case "trade":
err = g.TradeStart(c.playerID, msg.Cards) err = g.TradeStart(c.playerID, msg.Cards)
case "tradeChoose": case "tradeChoose":
+1 -1
View File
@@ -4,7 +4,7 @@ import { fetchCatalog } from '../api'
import { CardView } from './CardView' import { CardView } from './CardView'
interface Props { interface Props {
canGrant: boolean // shop/cleanup phase — grants only land then canGrant: boolean // shop phase — grants only land then
send: (msg: ClientMessage) => void send: (msg: ClientMessage) => void
} }
+109 -89
View File
@@ -18,8 +18,10 @@ interface Props {
export function ShopPhase({ view, you, send }: Props) { export function ShopPhase({ view, you, send }: Props) {
const [selected, setSelected] = useState<string[]>([]) const [selected, setSelected] = useState<string[]>([])
const cleanup = view.phase === 'cleanup' const [confirmPass, setConfirmPass] = useState(false)
const myTurn = !cleanup && view.turn === view.youSeat && you.coins > 0 const myTurn = view.turn === view.youSeat && !you.ready
const canBuy = myTurn && you.coins > 0
const overPets = you.petCount > view.maxPets
const deck = you.deck ?? [] const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat) const opponent = view.players.find((p) => p.seat !== view.youSeat)
const pending = view.pending const pending = view.pending
@@ -81,12 +83,6 @@ export function ShopPhase({ view, you, send }: Props) {
const sameSuit = const sameSuit =
selectedCards.length === 3 && selectedCards.length === 3 &&
selectedCards.every((c) => c.suit && c.suit === selectedCards[0].suit) selectedCards.every((c) => c.suit && c.suit === selectedCards[0].suit)
const excessPets = you.petCount - view.maxPets
const cleanupReady =
cleanup &&
excessPets > 0 &&
selectedCards.length === excessPets &&
selectedCards.every((c) => c.kind === 'pet')
function act(msg: ClientMessage) { function act(msg: ClientMessage) {
// Sold cards vanish before the apple entry arrives, so remember where they // Sold cards vanish before the apple entry arrives, so remember where they
@@ -105,24 +101,21 @@ export function ShopPhase({ view, you, send }: Props) {
<div className="shop"> <div className="shop">
{/* Status line */} {/* Status line */}
<div className="shop-status"> <div className="shop-status">
{cleanup ? ( {pending && !myPending ? (
excessPets > 0 ? (
<span className="status-hot">
Too many pets! Sell <strong>{excessPets}</strong> they become
apples 🍎
</span>
) : (
<span className="muted">
Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '}
{view.maxPets} pets
</span>
)
) : pending && !myPending ? (
<span className="muted"> <span className="muted">
{opponent?.name ?? 'Opponent'} is trading up a tier {opponent?.name ?? 'Opponent'} is trading up a tier
</span> </span>
) : you.ready ? (
<span className="muted">
You passed waiting for {opponent?.name ?? 'opponent'} to finish
shopping
</span>
) : myTurn && overPets ? (
<span className="status-hot">
Too many pets! Sell down to {view.maxPets} before you can pass 🍎
</span>
) : myTurn ? ( ) : myTurn ? (
<span className="status-hot">Your turn spend a coin 🪙</span> <span className="status-hot">Your turn buy, sell, trade, or pass</span>
) : ( ) : (
<span className="muted"> <span className="muted">
{view.players[view.turn]?.name ?? 'Opponent'}s turn {view.players[view.turn]?.name ?? 'Opponent'}s turn
@@ -131,30 +124,34 @@ export function ShopPhase({ view, you, send }: Props) {
</div> </div>
{/* Shop row */} {/* Shop row */}
{!cleanup && ( <section className="shop-row-wrap">
<section className="shop-row-wrap"> <div className="section-label">
<div className="section-label"> Shop · Tier {view.round}
Shop · Tier {view.round} <span className="muted"> · {view.deckCounts[view.round - 1]} left in deck</span>
<span className="muted"> · {view.deckCounts[view.round - 1]} left in deck</span> </div>
<div className="shop-row">
{view.shopRow.map((c, i) =>
c.id ? (
<CardView
key={c.id}
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => act({ type: 'buy', row: i }) : undefined}
/>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
</div>
{myTurn && (
<div className="hint">
{canBuy
? 'Tap a card to buy it for 1 🪙 — selling and trading are free'
: 'No coins left — you can still sell, trade, or pass'}
</div> </div>
<div className="shop-row"> )}
{view.shopRow.map((c, i) => </section>
c.id ? (
<CardView
key={c.id}
card={c}
size="lg"
disabled={!myTurn}
onClick={myTurn ? () => act({ type: 'buy', row: i }) : undefined}
/>
) : (
<div key={`empty-${i}`} className="card-slot-empty" />
),
)}
</div>
{myTurn && <div className="hint">Tap a card to buy it for 1 🪙</div>}
</section>
)}
{/* Your deck */} {/* Your deck */}
<section className="deck-wrap"> <section className="deck-wrap">
@@ -183,52 +180,75 @@ export function ShopPhase({ view, you, send }: Props) {
{/* Actions */} {/* Actions */}
<div className="actions"> <div className="actions">
{cleanup ? ( <button
excessPets > 0 && ( className="btn btn-secondary"
<button disabled={!myTurn || selected.length === 0}
className="btn btn-primary" onClick={() => act({ type: 'sell', cards: selected })}
disabled={!cleanupReady} title="Convert selected cards into apples (+1 power each, this battle only) — free"
onClick={() => act({ type: 'sell', cards: selected })} >
> Sell {selected.length > 0 ? selected.length : ''} 🍎
Sell {excessPets} pet{excessPets > 1 ? 's' : ''} 🍎 </button>
</button> <button
) className="btn btn-secondary"
) : ( disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
<> onClick={() => act({ type: 'trade', cards: selected })}
<button title="Trade 3 same-suit pets for a pick from the next tier — free"
className="btn btn-secondary" >
disabled={!myTurn || selected.length === 0} Trade 3{' '}
onClick={() => act({ type: 'sell', cards: selected })} {sameSuit && selectedCards[0].suit ? (
title="Convert selected cards into apples (+1 power each, this battle only)" <span className={`suit-dot suit-${selectedCards[0].suit}`} />
> ) : (
Sell {selected.length > 0 ? selected.length : ''} 🍎 (1 🪙) 'matching'
</button> )}{' '}
<button Tier {Math.min(view.round + 1, view.maxRounds)}
className="btn btn-secondary" </button>
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds} <button
onClick={() => act({ type: 'trade', cards: selected })} className="btn btn-ghost"
title="Trade 3 same-suit pets for a pick from the next tier" disabled={!myTurn || overPets}
> onClick={() => setConfirmPass(true)}
Trade 3{' '} title={
{sameSuit && selectedCards[0].suit ? ( overPets
<span className={`suit-dot suit-${selectedCards[0].suit}`} /> ? `Sell down to ${view.maxPets} pets before passing`
) : ( : 'End your shopping for this round'
'matching' }
)}{' '} >
Tier {Math.min(view.round + 1, view.maxRounds)} (1 🪙) Pass
</button> </button>
<button
className="btn btn-ghost"
disabled={!myTurn}
onClick={() => act({ type: 'pass' })}
title="Give up your remaining coins"
>
Pass
</button>
</>
)}
</div> </div>
{/* Pass confirmation */}
{confirmPass && (
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Done shopping?</h3>
<p className="muted">
Passing ends your shopping for the rest of this round
{you.coins > 0 && (
<>
{' '}
and gives up your remaining {you.coins} 🪙
</>
)}
.
</p>
<div className="actions">
<button className="btn btn-ghost" onClick={() => setConfirmPass(false)}>
Keep shopping
</button>
<button
className="btn btn-primary"
onClick={() => {
setConfirmPass(false)
act({ type: 'pass' })
}}
>
Pass
</button>
</div>
</div>
</div>
)}
{/* Trade picker */} {/* Trade picker */}
{myPending && pending && ( {myPending && pending && (
<div className="modal-backdrop"> <div className="modal-backdrop">
+3 -8
View File
@@ -102,7 +102,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} )}
<span className="topbar-name">{p.name}</span> <span className="topbar-name">{p.name}</span>
<span className="chip">🏆 {p.trophies}</span> <span className="chip">🏆 {p.trophies}</span>
{(view.phase === 'shop' || view.phase === 'cleanup') && ( {view.phase === 'shop' && (
<span className="chip">🪙 {p.coins}</span> <span className="chip">🪙 {p.coins}</span>
)} )}
</div> </div>
@@ -119,9 +119,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
<div className="table-body"> <div className="table-body">
<main className="table-main"> <main className="table-main">
{view.phase === 'lobby' && <Lobby view={view} />} {view.phase === 'lobby' && <Lobby view={view} />}
{(view.phase === 'shop' || view.phase === 'cleanup') && ( {view.phase === 'shop' && <ShopPhase view={view} you={you} send={send} />}
<ShopPhase view={view} you={you} send={send} />
)}
{view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />} {view.phase === 'arrange' && <ArrangePhase view={view} you={you} send={send} />}
{view.phase === 'battle' && ( {view.phase === 'battle' && (
<BattlePhase view={view} send={send} step={step} setStep={setStep} /> <BattlePhase view={view} send={send} step={step} setStep={setStep} />
@@ -138,10 +136,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} )}
{error && <div className="toast">{error}</div>} {error && <div className="toast">{error}</div>}
{view.debug && ( {view.debug && (
<DebugPanel <DebugPanel canGrant={view.phase === 'shop'} send={send} />
canGrant={view.phase === 'shop' || view.phase === 'cleanup'}
send={send}
/>
)} )}
</div> </div>
) )
+1 -1
View File
@@ -2,7 +2,7 @@
export type Suit = 'red' | 'blue' | 'yellow' export type Suit = 'red' | 'blue' | 'yellow'
export type CardKind = 'pet' | 'food' export type CardKind = 'pet' | 'food'
export type Phase = 'lobby' | 'shop' | 'cleanup' | 'arrange' | 'battle' | 'gameover' export type Phase = 'lobby' | 'shop' | 'arrange' | 'battle' | 'gameover'
export interface Card { export interface Card {
id: string id: string