diff --git a/README.md b/README.md index a625b9d..4e0cf22 100644 --- a/README.md +++ b/README.md @@ -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: -1. **Shop** β€” each player has 3 gold and players alternate actions, 1 gold - each: **buy** one of 4 face-up cards; **sell** any number of hand cards - (each becomes an 🍎 apple, +1 power food, and Sell effects fire); or - **trade in** 3 same-suit pets (the Triple action) to pick 1 of the top 2 - cards of the next tier's deck β€” Triple effects fire on the traded cards - and the received pet's Buy effect fires. Passing forfeits remaining gold. -2. **Cleanup** β€” anyone holding more than 5 pets must sell down to 5. -3. **Arrange** β€” Battle Prep effects fire first (e.g. Giraffe hands out +1. **Shop** β€” each player has 3 gold and players alternate actions: **buy** + one of 4 face-up cards (1 gold β€” the only action that costs anything); + **sell** any number of hand cards for free (each becomes an 🍎 apple, +1 + power food, and Sell effects fire); or **trade in** 3 same-suit pets (the + Triple action, also free) to pick 1 of the top 2 cards of the next tier's + deck β€” Triple effects fire on the traded cards and the received pet's Buy + effect fires. **Passing** is a final action, only legal at 5 pets or + 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 next pet after them; trailing foods are wasted. A pet only benefits from 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 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 diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 1a7b846..7caea96 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -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 diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 9867b74..bc3fa02 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -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)) diff --git a/internal/ai/memory.go b/internal/ai/memory.go index 9925bdd..eb95e5e 100644 --- a/internal/ai/memory.go +++ b/internal/ai/memory.go @@ -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}) diff --git a/internal/ai/shop.go b/internal/ai/shop.go index 19f02eb..4cc566f 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -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} -} diff --git a/internal/game/game.go b/internal/game/game.go index 381e5dd..b0a65e6 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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 { diff --git a/internal/game/game_test.go b/internal/game/game_test.go index 8a118c3..a17535d 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -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") diff --git a/internal/game/tier456_test.go b/internal/game/tier456_test.go index 0b76355..03f729d 100644 --- a/internal/game/tier456_test.go +++ b/internal/game/tier456_test.go @@ -130,7 +130,7 @@ func TestBisonBattlePrepRequiresTriple(t *testing.T) { t.Fatal(err) } } - spendAllCoins(t, g) + passShop(t, g) want := 0 if tripled { want = 3 diff --git a/internal/server/bot_e2e_test.go b/internal/server/bot_e2e_test.go index 1a1a690..800f3d0 100644 --- a/internal/server/bot_e2e_test.go +++ b/internal/server/bot_e2e_test.go @@ -65,11 +65,12 @@ func TestE2EBotGame(t *testing.T) { t.Fatal("no bot seat in the game") } - // Play the human side: pass whenever it's our turn. The game can only - // reach the arrange phase if the bot spends its own three coins too. + // Play the human side: pass on our first turn (passing is final, so one + // 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) 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"}) } rctx, rcancel := context.WithTimeout(ctx, 10*time.Second) diff --git a/internal/server/bots.go b/internal/server/bots.go index d9d26ae..b6e8a22 100644 --- a/internal/server/bots.go +++ b/internal/server/bots.go @@ -76,8 +76,6 @@ func botDelay(phase game.Phase) time.Duration { switch phase { case game.PhaseShop: return ms(700, 900) - case game.PhaseCleanup: - return ms(900, 600) case game.PhaseArrange: return ms(1600, 1600) default: // battle acknowledgement @@ -127,9 +125,6 @@ func applyBotAction(g *game.Game, playerID string, a *ai.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) @@ -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 -// waiting on: pass the shop turn, take the first trade option, sell the -// first excess pets, submit the deck as-is, or acknowledge the battle. +// waiting on: pass the shop turn (selling down to the pet limit first if +// 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 { p := g.PlayerByID(playerID) if p == nil { @@ -158,16 +154,16 @@ func botFallback(g *game.Game, playerID string) error { if g.Pending != nil && g.Pending.PlayerID == playerID { return g.TradeChoose(playerID, 0) } - return g.Pass(playerID) - case game.PhaseCleanup: - excess := p.PetCount() - game.MaxPets - ids := make([]string, 0, excess) - for _, c := range p.Deck { - if c.IsPet() && len(ids) < excess { - ids = append(ids, c.ID) + if excess := p.PetCount() - game.MaxPets; excess > 0 { + ids := make([]string, 0, excess) + for _, c := range p.Deck { + 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: ids := make([]string, len(p.Deck)) for i, c := range p.Deck { diff --git a/internal/server/ws.go b/internal/server/ws.go index a71cbaf..b23c9aa 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -122,11 +122,7 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { case "buy": err = g.Buy(c.playerID, msg.Row) case "sell": - if g.Phase == game.PhaseCleanup { - err = g.CleanupSell(c.playerID, msg.Cards) - } else { - err = g.Sell(c.playerID, msg.Cards) - } + err = g.Sell(c.playerID, msg.Cards) case "trade": err = g.TradeStart(c.playerID, msg.Cards) case "tradeChoose": diff --git a/web/src/components/DebugPanel.tsx b/web/src/components/DebugPanel.tsx index 09651d9..545bc5c 100644 --- a/web/src/components/DebugPanel.tsx +++ b/web/src/components/DebugPanel.tsx @@ -4,7 +4,7 @@ import { fetchCatalog } from '../api' import { CardView } from './CardView' interface Props { - canGrant: boolean // shop/cleanup phase β€” grants only land then + canGrant: boolean // shop phase β€” grants only land then send: (msg: ClientMessage) => void } diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 00ab4ab..140bdca 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -18,8 +18,10 @@ interface Props { export function ShopPhase({ view, you, send }: Props) { const [selected, setSelected] = useState([]) - const cleanup = view.phase === 'cleanup' - const myTurn = !cleanup && view.turn === view.youSeat && you.coins > 0 + const [confirmPass, setConfirmPass] = useState(false) + 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 opponent = view.players.find((p) => p.seat !== view.youSeat) const pending = view.pending @@ -81,12 +83,6 @@ export function ShopPhase({ view, you, send }: Props) { const sameSuit = selectedCards.length === 3 && 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) { // Sold cards vanish before the apple entry arrives, so remember where they @@ -105,24 +101,21 @@ export function ShopPhase({ view, you, send }: Props) {
{/* Status line */}
- {cleanup ? ( - excessPets > 0 ? ( - - Too many pets! Sell {excessPets} β€” they become - apples 🍎 - - ) : ( - - Waiting for {opponent?.name ?? 'opponent'} to sell down to{' '} - {view.maxPets} pets… - - ) - ) : pending && !myPending ? ( + {pending && !myPending ? ( {opponent?.name ?? 'Opponent'} is trading up a tier… + ) : you.ready ? ( + + You passed β€” waiting for {opponent?.name ?? 'opponent'} to finish + shopping… + + ) : myTurn && overPets ? ( + + Too many pets! Sell down to {view.maxPets} before you can pass 🍎 + ) : myTurn ? ( - Your turn β€” spend a coin πŸͺ™ + Your turn β€” buy, sell, trade, or pass ) : ( {view.players[view.turn]?.name ?? 'Opponent'}’s turn… @@ -131,30 +124,34 @@ export function ShopPhase({ view, you, send }: Props) {
{/* Shop row */} - {!cleanup && ( -
-
- Shop Β· Tier {view.round} - Β· {view.deckCounts[view.round - 1]} left in deck +
+
+ Shop Β· Tier {view.round} + Β· {view.deckCounts[view.round - 1]} left in deck +
+
+ {view.shopRow.map((c, i) => + c.id ? ( + act({ type: 'buy', row: i }) : undefined} + /> + ) : ( +
+ ), + )} +
+ {myTurn && ( +
+ {canBuy + ? 'Tap a card to buy it for 1 πŸͺ™ β€” selling and trading are free' + : 'No coins left β€” you can still sell, trade, or pass'}
-
- {view.shopRow.map((c, i) => - c.id ? ( - act({ type: 'buy', row: i }) : undefined} - /> - ) : ( -
- ), - )} -
- {myTurn &&
Tap a card to buy it for 1 πŸͺ™
} -
- )} + )} +
{/* Your deck */}
@@ -183,52 +180,75 @@ export function ShopPhase({ view, you, send }: Props) { {/* Actions */}
- {cleanup ? ( - excessPets > 0 && ( - - ) - ) : ( - <> - - - - - )} + + +
+ {/* Pass confirmation */} + {confirmPass && ( +
setConfirmPass(false)}> +
e.stopPropagation()}> +

Done shopping?

+

+ Passing ends your shopping for the rest of this round + {you.coins > 0 && ( + <> + {' '} + and gives up your remaining {you.coins} πŸͺ™ + + )} + . +

+
+ + +
+
+
+ )} + {/* Trade picker */} {myPending && pending && (
diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index 42b7049..27b992f 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -102,7 +102,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} {p.name} πŸ† {p.trophies} - {(view.phase === 'shop' || view.phase === 'cleanup') && ( + {view.phase === 'shop' && ( πŸͺ™ {p.coins} )}
@@ -119,9 +119,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.phase === 'lobby' && } - {(view.phase === 'shop' || view.phase === 'cleanup') && ( - - )} + {view.phase === 'shop' && } {view.phase === 'arrange' && } {view.phase === 'battle' && ( @@ -138,10 +136,7 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} {error &&
{error}
} {view.debug && ( - + )}
) diff --git a/web/src/types.ts b/web/src/types.ts index 6ab5343..6584d35 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -2,7 +2,7 @@ export type Suit = 'red' | 'blue' | 'yellow' 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 { id: string