Initial pass at Golden Pack.

This commit is contained in:
Greyson Parrelli
2026-07-24 07:47:05 -04:00
parent e74f983470
commit dd395f4bbf
27 changed files with 2770 additions and 150 deletions
+422
View File
@@ -0,0 +1,422 @@
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")
}
}