package game import "testing" // --- Golden pack test helpers --- // goldenGame builds a started 2-player game on the Golden pack, bypassing the // lobby and the Playable gate (tiers 4-6 aren't printed yet, so the pack isn't // selectable in a real lobby — but tiers 1-3 are fully exercisable here). func goldenGame(t *testing.T) (*Game, *Player, *Player) { t.Helper() g := New() g.Pack = "golden" g.buildDecks() p1, err := g.AddPlayer("Alice") if err != nil { t.Fatal(err) } p2, err := g.AddPlayer("Bob") if err != nil { t.Fatal(err) } g.start() g.PrioritySeat = p1.Seat g.Turn = p1.Seat return g, p1, p2 } // goldenPet mints a copy of a Golden pack pet (with effects) by name. func (g *Game) goldenPet(t *testing.T, name string) Card { t.Helper() for tierIdx, tier := range goldenPetTiers { for _, tmpl := range tier { if tmpl.Name == name { return Card{ ID: g.newCardID(), Kind: KindPet, Name: tmpl.Name, Tier: tierIdx + 1, Power: tmpl.Power, Suit: tmpl.Suits[0], Effects: tmpl.Effects, EffectText: tmpl.EffectText, } } } } t.Fatalf("no golden pet named %s", name) return Card{} } // goldenFood mints a Golden pack food (Avocado) by name. func (g *Game) goldenFood(t *testing.T, name string) Card { t.Helper() for tierIdx, tier := range goldenFoodTiers { for _, f := range tier { if f.Name == name { return Card{ ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText, } } } } t.Fatalf("no golden food named %s", name) return Card{} } // suitedPet mints a plain effect-less pet with a chosen suit. func (g *Game) suitedPet(name string, power int, suit Suit) Card { return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: suit} } // --- Trumpets & Golden Retriever --- // A fainting Groundhog banks a Trumpet; with no cards left, the side fields a // Golden Retriever whose Power equals its Trumpets — and it wins. func TestGroundhogTrumpetThenGoldenRetriever(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.goldenPet(t, "Groundhog")}, // pow 1, faint: +1 trumpet []Card{g.pet("Weak", 1)}, ) trumpets := eventsOfType(res, "trumpet") if len(trumpets) != 1 || trumpets[0].Seat != 0 || trumpets[0].Count != 1 { t.Fatalf("expected one +1 trumpet event for seat 0: %+v", trumpets) } var gr *BattleEvent for i, ev := range res.Events { if ev.Type == "reveal" && ev.Card != nil && ev.Card.Name == "Golden Retriever" { gr = &res.Events[i] } } if gr == nil { t.Fatal("expected a Golden Retriever to be summoned") } if gr.Card.Power != 1 || gr.Count != 1 { t.Fatalf("Golden Retriever should have Power 1 from 1 trumpet: power=%d count=%d", gr.Card.Power, gr.Count) } if res.WinnerSeat != 0 { t.Fatalf("the Golden Retriever should win for seat 0, got %d", res.WinnerSeat) } } // Pied Tamarin spends a banked Trumpet on entry to throw 2 Rocks. func TestPiedTamarinSpendsTrumpetForRocks(t *testing.T) { g, _, _ := testGame(t) g.RollDie = func() int { return 2 } // each rock deals 2 res := forceBattle(t, g, []Card{g.goldenPet(t, "Groundhog"), g.goldenPet(t, "Pied Tamarin")}, []Card{g.pet("Weak", 1), g.pet("Tank", 3)}, ) // Groundhog trades with Weak (+1 trumpet). Pied Tamarin enters, spends the // trumpet, throws 2 rocks (2+2=4) and kills the 3-power Tank. spends := 0 for _, ev := range eventsOfType(res, "trumpet") { if ev.Count < 0 { spends++ } } if spends != 1 { t.Fatalf("expected exactly one trumpet spend, got %d: %+v", spends, eventsOfType(res, "trumpet")) } rocks := eventsOfType(res, "rock") if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied { t.Fatalf("Pied Tamarin should throw 2 rocks (roll 4) and kill the Tank: %+v", rocks) } if res.WinnerSeat != 0 { t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) } } // Without a Trumpet, Pied Tamarin's paid Play effect does nothing. func TestPiedTamarinNoTrumpetNoRocks(t *testing.T) { g, _, _ := testGame(t) g.RollDie = func() int { return 2 } res := forceBattle(t, g, []Card{g.goldenPet(t, "Pied Tamarin")}, []Card{g.pet("Tank", 3)}, ) if rocks := eventsOfType(res, "rock"); len(rocks) != 0 { t.Fatalf("no trumpet means no rocks: %+v", rocks) } } // Honduran White Bat banks a Trumpet per distinct suit among friendly fainted // pets. func TestHonduranBatTrumpetPerFaintedSuit(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{ g.suitedPet("Red", 1, SuitRed), g.suitedPet("Blue", 1, SuitBlue), g.goldenPet(t, "Honduran White Bat"), }, []Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)}, ) // Red and Blue trade with K1/K2 (two distinct fainted suits). The Bat // enters and banks 2 Trumpets. var gain *BattleEvent for i, ev := range res.Events { if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count > 0 { gain = &res.Events[i] } } if gain == nil || gain.Count != 2 { t.Fatalf("Bat should bank 2 trumpets for 2 unique fainted suits: %+v", eventsOfType(res, "trumpet")) } } // Flea drains Trumpets from the opponent when it faints. func TestFleaDrainsEnemyTrumpets(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.goldenPet(t, "Flea")}, // pow 2 []Card{g.goldenPet(t, "Groundhog"), g.pet("Big", 5)}, // Groundhog banks a trumpet for seat 1 ) // Flea kills Groundhog (seat 1 +1 trumpet), survives, then dies to Big and // drains that trumpet back down to 0. var drain *BattleEvent for i, ev := range res.Events { if ev.Type == "trumpet" && ev.Seat == 1 && ev.Count < 0 { drain = &res.Events[i] } } if drain == nil { t.Fatalf("Flea should drain a trumpet from seat 1: %+v", eventsOfType(res, "trumpet")) } } // --- Cone Snail --- // Cone Snail's faint sets aside a one-shot 2-damage prevention for the next // friendly hit. func TestConeSnailPreventsTwoDamage(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.goldenPet(t, "Cone Snail"), g.pet("Ally", 5)}, []Card{g.pet("Big", 6)}, ) // Cone Snail dies to the 6 (Big takes 1). Ally enters; Big's next hit of 6 // is shaved to 4, so Ally (5) survives and kills Big. prevents := eventsOfType(res, "prevent") if len(prevents) != 1 || prevents[0].Seat != 0 || prevents[0].Count != 2 { t.Fatalf("expected one 2-damage prevention for seat 0: %+v", prevents) } clashes := eventsOfType(res, "clash") final := clashes[len(clashes)-1] if final.Damage[0] != 4 || !final.Died[1] { t.Fatalf("Ally should take 4 (6-2) and Big should die: %+v", final) } if res.WinnerSeat != 0 { t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) } } // --- Bulldog --- // Bulldog eats an apple after attacking, but only once per battle. func TestBulldogAfterAttackOncePerRound(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.goldenPet(t, "Bulldog")}, // pow 2 []Card{g.pet("W1", 1), g.pet("W2", 1)}, ) eats := eventsOfType(res, "eat") if len(eats) != 1 || eats[0].Seat != 0 || eats[0].Bonus != 1 { t.Fatalf("Bulldog should eat exactly one apple across the battle: %+v", eats) } if res.WinnerSeat != 0 { t.Fatalf("Bulldog should survive and win, got %d", res.WinnerSeat) } } // --- Bear --- // Bear's faint drops a Bee on the bottom of BOTH decks. func TestBearBeeToBothDecks(t *testing.T) { g, _, _ := testGame(t) res := forceBattle(t, g, []Card{g.goldenPet(t, "Bear")}, // pow 4 []Card{g.pet("Big", 5)}, ) seats := map[int]bool{} for _, ev := range eventsOfType(res, "summon") { if ev.Card != nil && ev.Card.Name == "Bee" { seats[ev.Seat] = true } } if !seats[0] || !seats[1] { t.Fatalf("Bear should summon a Bee onto both decks, saw seats %v", seats) } } // --- Royal Flycatcher --- // Royal Flycatcher throws one Rock per enemy pet already fainted. func TestRoyalFlycatcherRocksPerEnemyFaint(t *testing.T) { g, _, _ := testGame(t) g.RollDie = func() int { return 2 } res := forceBattle(t, g, []Card{g.pet("K1", 1), g.pet("K2", 1), g.goldenPet(t, "Royal Flycatcher")}, []Card{g.pet("E1", 1), g.pet("E2", 1), g.pet("Tank", 3)}, ) // K1/K2 trade with E1/E2 (2 enemy faints). Royal Flycatcher enters and // throws 2 rocks (2+2=4), killing the 3-power Tank. rocks := eventsOfType(res, "rock") if len(rocks) != 1 || len(rocks[0].Dice) != 2 || !rocks[0].TargetDied { t.Fatalf("Royal Flycatcher should throw 2 rocks (one per enemy faint) and kill Tank: %+v", rocks) } if res.WinnerSeat != 0 { t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) } } // --- Shop-time effects --- // Selling a Chipmunk yields its base apple plus 2 extra. func TestChipmunkSellApples(t *testing.T) { g, p1, p2 := goldenGame(t) chip := g.goldenPet(t, "Chipmunk") p1.Deck = append(p1.Deck, chip) // Hand the turn back to seat 0 after seat 1 acts. _ = p2 g.Turn = 0 if err := g.Sell(p1.ID, []string{chip.ID}); err != nil { t.Fatal(err) } if countApplesIn(p1.Deck) != 3 { t.Fatalf("selling a Chipmunk should add 3 apples (1 base + 2 extra), got %d", countApplesIn(p1.Deck)) } } // Opossum's sell adds an apple per Faint pet currently in the shop. func TestOpossumSellApplesPerShopFaint(t *testing.T) { g, p1, _ := goldenGame(t) op := g.goldenPet(t, "Opossum") p1.Deck = append(p1.Deck, op) g.ShopRow = []Card{ g.goldenPet(t, "Groundhog"), // faint g.goldenPet(t, "Cone Snail"), // faint g.goldenPet(t, "Bulldog"), // after-attack, no faint g.goldenPet(t, "Pied Tamarin"), // play, no faint } g.Turn = 0 if err := g.Sell(p1.ID, []string{op.ID}); err != nil { t.Fatal(err) } // 1 base apple + 2 (one per faint pet in shop). if countApplesIn(p1.Deck) != 3 { t.Fatalf("selling Opossum with 2 shop faint pets should add 3 apples, got %d", countApplesIn(p1.Deck)) } } // Stoat's sell pulls the top of the current tier's shop deck for free. func TestStoatSellBuysTopOfDeck(t *testing.T) { g, p1, _ := goldenGame(t) stoat := g.goldenPet(t, "Stoat") p1.Deck = append(p1.Deck, stoat) g.Turn = 0 deckBefore := len(g.ShopDecks[g.Round-1]) petsBefore := p1.PetCount() if err := g.Sell(p1.ID, []string{stoat.ID}); err != nil { t.Fatal(err) } if got := len(g.ShopDecks[g.Round-1]); got != deckBefore-1 { t.Fatalf("Stoat should draw one card from the tier deck: before %d, after %d", deckBefore, got) } // Stoat itself became an apple, but a fresh pet came from the deck, so the // pet count is unchanged (–1 Stoat, +1 grabbed pet, assuming a pet on top). if p1.PetCount() < petsBefore { t.Fatalf("Stoat should have replaced itself with a grabbed pet: before %d, after %d", petsBefore, p1.PetCount()) } } // Hercules Beetle banks apples-in-play only from round 3 on, and the battle // consumes them onto the first pet. func TestHerculesBeetleApplesInPlay(t *testing.T) { g, p1, _ := goldenGame(t) // Round 2: no effect. g.Round = 2 hb2 := g.goldenPet(t, "Hercules Beetle") p1.Deck = append(p1.Deck, hb2) g.Turn = 0 if err := g.Sell(p1.ID, []string{hb2.ID}); err != nil { t.Fatal(err) } if p1.PendingApplesInPlay != 0 { t.Fatalf("Hercules Beetle should do nothing before round 3, got %d", p1.PendingApplesInPlay) } // Round 3: banks 3. g.Round = 3 hb3 := g.goldenPet(t, "Hercules Beetle") p1.Deck = append(p1.Deck, hb3) g.Turn = 0 if err := g.Sell(p1.ID, []string{hb3.ID}); err != nil { t.Fatal(err) } if p1.PendingApplesInPlay != 3 { t.Fatalf("Hercules Beetle in round 3 should bank 3 apples-in-play, got %d", p1.PendingApplesInPlay) } // The battle seeds those apples onto the first pet, then resets. res := forceBattle(t, g, []Card{g.pet("Hero", 3)}, []Card{g.pet("Foe", 1)}, ) if preps := eventsOfType(res, "prep"); len(preps) != 3 { t.Fatalf("expected 3 apples-in-play prep events, got %d", len(preps)) } if p1.PendingApplesInPlay != 0 { t.Fatalf("PendingApplesInPlay should reset after the battle, got %d", p1.PendingApplesInPlay) } } // --- Avocado --- // Buying an Avocado sets it aside (not into the deck); a later buy can spend it // instead of a coin. func TestAvocadoBuyAndDiscardToBuy(t *testing.T) { g, p1, _ := goldenGame(t) g.ShopRow[0] = g.goldenFood(t, "Avocado") g.Turn = 0 coins := p1.Coins if err := g.Buy(p1.ID, 0); err != nil { t.Fatal(err) } if p1.Avocados != 1 { t.Fatalf("buying an Avocado should set aside 1, got %d", p1.Avocados) } if p1.Coins != coins-1 { t.Fatalf("buying an Avocado still costs a coin: before %d, after %d", coins, p1.Coins) } for _, c := range p1.Deck { if c.Food == FoodAvocado { t.Fatal("the Avocado should not be in the deck") } } // Now discard the Avocado to buy the card in slot 1, spending no coin. g.Turn = 0 deckLen := len(p1.Deck) coins = p1.Coins if err := g.BuyAvocado(p1.ID, 1); err != nil { t.Fatal(err) } if p1.Avocados != 0 { t.Fatalf("the discard should consume the Avocado, got %d", p1.Avocados) } if p1.Coins != coins { t.Fatalf("a discard-buy should spend no coin: before %d, after %d", coins, p1.Coins) } if len(p1.Deck) != deckLen+1 { t.Fatalf("the discard-buy should add a card to the deck: before %d, after %d", deckLen, len(p1.Deck)) } } // BuyAvocado is refused with no Avocado in the stash. func TestBuyAvocadoRequiresToken(t *testing.T) { g, p1, _ := goldenGame(t) g.Turn = 0 if err := g.BuyAvocado(p1.ID, 0); err == nil { t.Fatal("expected an error buying with no Avocado") } }