package game import ( "testing" ) func deckIDs(p *Player, filter func(Card) bool) []string { var ids []string for _, c := range p.Deck { if filter == nil || filter(c) { ids = append(ids, c.ID) } } return ids } func current(g *Game) *Player { return g.Players[g.Turn] } func TestLobbyStartsWhenFull(t *testing.T) { g := New() if g.Phase != PhaseLobby { t.Fatalf("new game should be in lobby, got %s", g.Phase) } if _, err := g.AddPlayer("Alice"); err != nil { t.Fatal(err) } if g.Phase != PhaseLobby { t.Fatal("game should wait for second player") } if _, err := g.AddPlayer("Bob"); err != nil { t.Fatal(err) } if g.Phase != PhaseShop || g.Round != 1 { t.Fatalf("game should start round 1 shop, got phase=%s round=%d", g.Phase, g.Round) } if _, err := g.AddPlayer("Carol"); err == nil { t.Fatal("third player should be rejected while MaxPlayers=2") } for _, p := range g.Players { if p.Coins != CoinsPerRound { t.Fatalf("player should start with %d coins", CoinsPerRound) } } if len(g.ShopRow) != ShopRowSize { t.Fatalf("shop row should have %d cards", ShopRowSize) } for _, c := range g.ShopRow { if c.Tier != 1 { t.Fatalf("round 1 shop should deal tier 1 cards, got tier %d", c.Tier) } } } func TestBuyTakesCardAndRefills(t *testing.T) { g, _, _ := testGame(t) p := current(g) want := g.ShopRow[0] deckBefore := len(g.ShopDecks[0]) if err := g.Buy(p.ID, 0); err != nil { t.Fatal(err) } if p.Coins != CoinsPerRound-1 { t.Fatalf("buy should cost 1 coin, coins=%d", p.Coins) } if len(p.Deck) != 1 || p.Deck[0].ID != want.ID { t.Fatalf("bought card should be in deck") } if g.ShopRow[0].ID == "" || g.ShopRow[0].ID == want.ID { t.Fatal("shop slot should refill with a new card") } if len(g.ShopDecks[0]) != deckBefore-1 { t.Fatal("refill should come from the tier deck") } if current(g).ID == p.ID { t.Fatal("turn should pass after an action") } } func TestTurnValidation(t *testing.T) { g, _, _ := testGame(t) other := g.Players[(g.Turn+1)%2] if err := g.Buy(other.ID, 0); err == nil { t.Fatal("acting out of turn should fail") } } func TestSellConvertsToApples(t *testing.T) { g, _, _ := testGame(t) p := current(g) // Sell a plain pet (no sell effect) for exactly one apple. plain := g.pet("Plain", 2) p.Deck = append(p.Deck, plain) 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.PetCount() != 0 || len(p.Deck) != 1 || p.Deck[0].Food != FoodApple { t.Fatalf("sold pet should become an apple: %+v", p.Deck) } } func TestSellDuckAddsExtraApple(t *testing.T) { g, _, _ := testGame(t) p := current(g) duck := g.tier1(t, "Duck") p.Deck = append(p.Deck, duck) if err := g.Sell(p.ID, []string{duck.ID}); err != nil { t.Fatal(err) } apples := 0 for _, c := range p.Deck { if c.Food == FoodApple { apples++ } } if apples != 2 { t.Fatalf("selling a Duck should yield 2 apples (1 base + 1 effect), got %d", apples) } } func TestBuyOtterAddsApple(t *testing.T) { g, _, _ := testGame(t) p := current(g) g.ShopRow[0] = g.tier1(t, "Otter") if err := g.Buy(p.ID, 0); err != nil { t.Fatal(err) } hasOtter, apples := false, 0 for _, c := range p.Deck { if c.Name == "Otter" { hasOtter = true } if c.Food == FoodApple { apples++ } } if !hasOtter || apples != 1 { t.Fatalf("buying an Otter should also add an apple: %+v", p.Deck) } } func TestTradeInThreeMatchingSuits(t *testing.T) { g, _, _ := testGame(t) p := current(g) // Hand p three same-suit tier-1 pets directly. for range 3 { c := g.pet("Fodder", 1) c.Suit = SuitBlue p.Deck = append(p.Deck, c) } nextDeckBefore := len(g.ShopDecks[1]) if err := g.TradeStart(p.ID, deckIDs(p, nil)); err != nil { t.Fatal(err) } if g.Pending == nil || g.Pending.Tier != 2 { t.Fatalf("trade should reveal two tier-2 cards: %+v", g.Pending) } if len(p.Deck) != 0 { t.Fatal("traded cards should leave the deck") } if current(g).ID != p.ID { t.Fatal("turn should not pass until the trade is chosen") } if err := g.Buy(p.ID, 0); err == nil { t.Fatal("other actions should be blocked while a trade is pending") } chosen := g.Pending.Options[0] rejected := g.Pending.Options[1] if err := g.TradeChoose(p.ID, 0); err != nil { t.Fatal(err) } if len(p.Deck) != 1 || p.Deck[0].ID != chosen.ID { t.Fatal("chosen card should join the deck") } deck2 := g.ShopDecks[1] if len(deck2) != nextDeckBefore-1 { t.Fatalf("tier 2 deck should be down exactly one card, was %d now %d", nextDeckBefore, len(deck2)) } 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) } } // Fish fires its Triple effect when traded in, and a pet received from the // trade fires its Buy effect. func TestTradeTriggersTripleAndBuyEffects(t *testing.T) { g, _, _ := testGame(t) p := current(g) countApples := func() int { n := 0 for _, c := range p.Deck { if c.Food == FoodApple { n++ } } return n } var ids []string for range 3 { f := g.tier1(t, "Fish") f.Suit = SuitYellow p.Deck = append(p.Deck, f) ids = append(ids, f.ID) } if err := g.TradeStart(p.ID, ids); err != nil { t.Fatal(err) } if countApples() != 3 { t.Fatalf("each traded Fish should add an apple, got %d", countApples()) } // Rig the revealed options so the chosen card is an Otter (Buy effect). g.Pending.Options[0] = g.tier1(t, "Otter") if err := g.TradeChoose(p.ID, 0); err != nil { t.Fatal(err) } if countApples() != 4 { t.Fatalf("trade-received Otter should fire its Buy effect, got %d apples", countApples()) } } func TestBuyWormAddsTwoApples(t *testing.T) { g, _, _ := testGame(t) p := current(g) g.ShopRow[0] = g.realPet(t, "Worm") if err := g.Buy(p.ID, 0); err != nil { t.Fatal(err) } apples := 0 for _, c := range p.Deck { if c.Food == FoodApple { apples++ } } if apples != 2 { t.Fatalf("buying a Worm should add 2 apples, got %d", apples) } } // Swan's Triple refreshes a spent gold, but only from round 3 on. func TestSwanTripleRefreshesGold(t *testing.T) { for _, tc := range []struct { round int wantCoins int }{ {round: 1, wantCoins: CoinsPerRound - 1}, // too early: coin stays spent {round: 3, wantCoins: CoinsPerRound}, // refreshed (capped at 3) } { g, _, _ := testGame(t) g.Round = tc.round p := current(g) var ids []string for range 3 { s := g.realPet(t, "Swan") s.Suit = SuitRed p.Deck = append(p.Deck, s) ids = append(ids, s.ID) } if err := g.TradeStart(p.ID, ids); err != nil { t.Fatal(err) } if p.Coins != tc.wantCoins { t.Fatalf("round %d: coins after swan trade = %d, want %d", tc.round, p.Coins, tc.wantCoins) } } } // Giraffe's Battle Prep hands out apples the moment arranging begins. func TestGiraffeBattlePrep(t *testing.T) { g, p1, _ := testGame(t) p1.Deck = append(p1.Deck, g.realPet(t, "Giraffe")) spendAllCoins(t, g) if g.Phase != PhaseArrange { t.Fatalf("expected arrange, got %s", g.Phase) } apples := 0 for _, c := range p1.Deck { if c.Food == FoodApple { apples++ } } if apples != 2 { t.Fatalf("giraffe should add 2 apples at battle prep, got %d", apples) } } func TestTradeRequiresMatchingSuit(t *testing.T) { g, _, _ := testGame(t) p := current(g) a, b, c := g.pet("A", 1), g.pet("B", 1), g.pet("C", 1) a.Suit, b.Suit, c.Suit = SuitRed, SuitRed, SuitBlue p.Deck = append(p.Deck, a, b, c) if err := g.TradeStart(p.ID, []string{a.ID, b.ID, c.ID}); err == nil { t.Fatal("mismatched suits should be rejected") } if len(p.Deck) != 3 || p.Coins != CoinsPerRound { t.Fatal("failed trade must not mutate state") } } func TestTradeBlockedOnFinalRound(t *testing.T) { g, _, _ := testGame(t) g.Round = MaxRounds p := current(g) for range 3 { c := g.pet("Fodder", 1) c.Suit = SuitBlue p.Deck = append(p.Deck, c) } if err := g.TradeStart(p.ID, deckIDs(p, nil)); err == nil { t.Fatal("trading should be impossible in the final round") } } // spendAllCoins has both players pass until the shop ends. func spendAllCoins(t *testing.T, g *Game) { t.Helper() for g.Phase == PhaseShop { if err := g.Pass(current(g).ID); err != nil { t.Fatal(err) } } } func TestShopEndsIntoArrange(t *testing.T) { g, _, _ := testGame(t) spendAllCoins(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) } } func TestForcedDiscardOverPetLimit(t *testing.T) { g, p1, _ := testGame(t) 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) } // 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 { t.Fatal(err) } if p1.PetCount() != MaxPets { t.Fatalf("expected %d pets after cleanup, got %d", MaxPets, p1.PetCount()) } apples := 0 for _, c := range p1.Deck { if c.Food == FoodApple { apples++ } } if apples != 2 { t.Fatalf("discarded pets should become apples, got %d", apples) } if g.Phase != PhaseArrange { t.Fatalf("cleanup 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) if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID}); err == nil { t.Fatal("partial order should be rejected") } if err := g.SubmitOrder(p1.ID, []string{p1.Deck[0].ID, p1.Deck[0].ID}); err == nil { t.Fatal("duplicate IDs should be rejected") } if err := g.SubmitOrder(p1.ID, []string{p1.Deck[1].ID, p1.Deck[0].ID}); err != nil { t.Fatal(err) } if p1.Deck[0].Name != "B" { t.Fatal("submitted order should be applied to the deck") } } // Full game: six rounds of pass-through shops and battles, trophy totals, // and game over with a winner. func TestFullGameFlow(t *testing.T) { g, p1, p2 := testGame(t) p1.Deck = append(p1.Deck, g.pet("Champ", 9)) p2.Deck = append(p2.Deck, g.pet("Chump", 1)) for round := 1; round <= MaxRounds; round++ { if g.Round != round || g.Phase != PhaseShop { t.Fatalf("expected shop of round %d, got round %d phase %s", round, g.Round, g.Phase) } if g.Turn != (round-1)%len(g.Players) { t.Fatalf("round %d should rotate the starting player, turn=%d", round, g.Turn) } for _, c := range g.ShopRow { if c.ID != "" && c.Tier != round { t.Fatalf("round %d shop dealt tier %d card", round, c.Tier) } } spendAllCoins(t, g) for _, p := range g.Players { if err := g.SubmitOrder(p.ID, deckIDs(p, nil)); err != nil { t.Fatal(err) } } if g.Phase != PhaseBattle { t.Fatalf("expected battle after both arrange, got %s", g.Phase) } if g.Battle.WinnerSeat != p1.Seat { t.Fatalf("round %d: seat 0 should win", round) } for _, p := range g.Players { if err := g.AcknowledgeBattle(p.ID); err != nil { t.Fatal(err) } } } if g.Phase != PhaseGameOver { t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase) } // 1 trophy for rounds 1-5, 2 for round 6. if p1.Trophies != 7 { t.Fatalf("winner should have 7 trophies, got %d", p1.Trophies) } if g.WinnerSeat != p1.Seat { t.Fatalf("winner seat should be %d, got %d", p1.Seat, g.WinnerSeat) } } func TestViewHidesSecrets(t *testing.T) { g, p1, p2 := testGame(t) p1.Deck = append(p1.Deck, g.pet("Secret", 3)) v := g.ViewFor(p2.ID) if v.YouSeat != p2.Seat { t.Fatalf("view should identify the viewer's seat") } for _, pv := range v.Players { if pv.Seat == p1.Seat && pv.Deck != nil { t.Fatal("opponent deck contents must be hidden") } if pv.Seat == p1.Seat && pv.DeckSize != 1 { t.Fatal("opponent deck size should be visible") } } // Pending trade options hidden from the opponent. for range 3 { c := g.pet("Fodder", 1) c.Suit = SuitYellow p1.Deck = append(p1.Deck, c) } g.Turn = p1.Seat var fodder []string for _, c := range p1.Deck { if c.Name == "Fodder" { fodder = append(fodder, c.ID) } } if err := g.TradeStart(p1.ID, fodder); err != nil { t.Fatal(err) } if opts := g.ViewFor(p2.ID).Pending.Options; opts[0].ID != "" || opts[1].ID != "" { t.Fatal("trade options must be hidden from opponents") } if opts := g.ViewFor(p1.ID).Pending.Options; opts[0].ID == "" { t.Fatal("trade options must be visible to the trader") } }