396 lines
13 KiB
Go
396 lines
13 KiB
Go
package game
|
|
|
|
import "testing"
|
|
|
|
// --- Nurse Shark ---
|
|
|
|
// Nurse Shark automatically spends every available Trumpet (up to 3), throwing
|
|
// two rocks per Trumpet spent.
|
|
func TestNurseSharkSpendsAllTrumpets(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 2 } // each rock deals 2
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")}, // Nyala faint: +2 trumpets
|
|
[]Card{g.pet("Big", 4), g.pet("Tank", 6)},
|
|
)
|
|
// Nyala trades with Big (banking 2 Trumpets), then Nurse Shark enters and
|
|
// spends both: 4 rocks (2+2+2+2 = 8) kill the 6-power Tank.
|
|
spends := 0
|
|
for _, ev := range eventsOfType(res, "trumpet") {
|
|
if ev.Count < 0 {
|
|
spends++
|
|
}
|
|
}
|
|
if spends != 1 {
|
|
t.Fatalf("expected one trumpet spend, got %d", spends)
|
|
}
|
|
rocks := eventsOfType(res, "rock")
|
|
if len(rocks) != 1 || len(rocks[0].Dice) != 4 || !rocks[0].TargetDied {
|
|
t.Fatalf("Nurse Shark should throw 4 rocks (2 per Trumpet) and kill Tank: %+v", rocks)
|
|
}
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
|
}
|
|
}
|
|
|
|
// The spend is capped at 3 Trumpets even when more are banked.
|
|
func TestNurseSharkCapsAtThree(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 1 } // each rock deals 1
|
|
g.Players[0].PendingTrumpets = 5 // seed a pool larger than the cap
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Nurse Shark")},
|
|
[]Card{g.pet("Wall", 20)},
|
|
)
|
|
var spend *BattleEvent
|
|
for i, ev := range res.Events {
|
|
if ev.Type == "trumpet" && ev.Count < 0 {
|
|
spend = &res.Events[i]
|
|
}
|
|
}
|
|
if spend == nil || spend.Count != -3 {
|
|
t.Fatalf("Nurse Shark should spend exactly 3 Trumpets: %+v", eventsOfType(res, "trumpet"))
|
|
}
|
|
rocks := eventsOfType(res, "rock")
|
|
if len(rocks) != 1 || len(rocks[0].Dice) != 6 {
|
|
t.Fatalf("spending 3 Trumpets should throw 6 rocks: %+v", rocks)
|
|
}
|
|
}
|
|
|
|
// With no Trumpets, Nurse Shark throws no rocks and the battle still resolves.
|
|
func TestNurseSharkNoTrumpets(t *testing.T) {
|
|
res := SimulateBattle(1, 0,
|
|
[]Card{cardWithName("Nurse Shark", 3, []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}})},
|
|
[]Card{{ID: "x", Kind: KindPet, Name: "Foe", Power: 2}},
|
|
func() int { return 2 },
|
|
)
|
|
if res == nil || res.WinnerSeat < -1 {
|
|
t.Fatalf("battle should resolve: %+v", res)
|
|
}
|
|
if len(eventsOfType(res, "rock")) != 0 {
|
|
t.Fatal("with no Trumpets, Nurse Shark should throw no rocks")
|
|
}
|
|
}
|
|
|
|
func cardWithName(name string, power int, eff []Effect) Card {
|
|
return Card{ID: "t-" + name, Kind: KindPet, Name: name, Power: power, Effects: eff}
|
|
}
|
|
|
|
// --- Other tier 4-5 battle mechanics ---
|
|
|
|
// Vaquita doubles the side's Trumpets, capped at 4 gained.
|
|
func TestVaquitaDoublesTrumpets(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Vaquita")}, // Nyala faint: +2
|
|
[]Card{g.pet("Big", 4), g.pet("Wall", 20)},
|
|
)
|
|
// Nyala trades with Big (+2 Trumpets), Vaquita enters and doubles to 4.
|
|
var gain *BattleEvent
|
|
for i, ev := range res.Events {
|
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 {
|
|
// two events: Nyala's +2 gain and Vaquita's +2 double.
|
|
gain = &res.Events[i]
|
|
}
|
|
}
|
|
if gain == nil {
|
|
t.Fatalf("Vaquita should double 2 Trumpets into 2 more: %+v", eventsOfType(res, "trumpet"))
|
|
}
|
|
}
|
|
|
|
// Manatee throws rocks at itself.
|
|
func TestManateeSelfRock(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 1 } // each rock deals 1
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Manatee")},
|
|
[]Card{g.pet("Wall", 20)},
|
|
)
|
|
var self *BattleEvent
|
|
for i, ev := range res.Events {
|
|
if ev.Type == "rock" && ev.Seat == 0 && ev.Target == 0 {
|
|
self = &res.Events[i]
|
|
}
|
|
}
|
|
if self == nil || self.Roll != 2 {
|
|
t.Fatalf("Manatee should pelt itself for 2: %+v", eventsOfType(res, "rock"))
|
|
}
|
|
// It also stacks 4 apples on top of the deck.
|
|
apples := 0
|
|
for _, ev := range eventsOfType(res, "summon") {
|
|
if ev.Card != nil && ev.Card.Name == "Apple" {
|
|
apples++
|
|
}
|
|
}
|
|
if apples != 4 {
|
|
t.Fatalf("Manatee should add 4 apples, got %d", apples)
|
|
}
|
|
}
|
|
|
|
// Even when its own rocks are lethal, the Manatee still stacks all 4 apples —
|
|
// the summon is posthumous, so it fires after the Manatee faints.
|
|
func TestManateeSelfRockFatalStillAddsApples(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 2 } // 2 rocks = 4 damage, past its 3 power
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Manatee")},
|
|
[]Card{g.pet("Wall", 20)},
|
|
)
|
|
var self *BattleEvent
|
|
for i, ev := range res.Events {
|
|
if ev.Type == "rock" && ev.Target == 0 {
|
|
self = &res.Events[i]
|
|
}
|
|
}
|
|
if self == nil || !self.TargetDied {
|
|
t.Fatalf("Manatee's own rocks should faint it: %+v", eventsOfType(res, "rock"))
|
|
}
|
|
apples := 0
|
|
for _, ev := range eventsOfType(res, "summon") {
|
|
if ev.Card != nil && ev.Card.Name == "Apple" {
|
|
apples++
|
|
}
|
|
}
|
|
if apples != 4 {
|
|
t.Fatalf("Manatee should still add 4 apples after fainting, got %d", apples)
|
|
}
|
|
}
|
|
|
|
// A player whose only in-play pet faints to its own play effect (Manatee's
|
|
// self-rock) still wins if it has pets left in its deck and the opponent has
|
|
// none. The battle loop breaks the instant the opponent runs out — before the
|
|
// survivor's next pet is revealed — so the winner must be decided by who can
|
|
// still field a pet, not by who happens to have one in play at that instant.
|
|
func TestSelfFaintWithReserveStillWins(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 2 } // 2 rocks = 4 damage, fatal to the 3-power Manatee
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Manatee"), g.pet("Reserve", 5)},
|
|
[]Card{g.newApple()}, // opponent has only food — no pet, ever
|
|
)
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("seat 0 should win with a reserve pet vs an all-food deck, got winner %d", res.WinnerSeat)
|
|
}
|
|
}
|
|
|
|
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
|
|
func TestPoisonDartFrogBeeRocks(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
g.RollDie = func() int { return 2 }
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Poison Dart Frog"), g.newBee()},
|
|
[]Card{g.pet("Killer", 2), g.pet("Tank", 1)},
|
|
)
|
|
// Frog trades with Killer (set aside); the Bee enters and triggers 2 rocks
|
|
// (roll 4) that kill the 1-power Tank.
|
|
rocks := eventsOfType(res, "rock")
|
|
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
|
|
t.Fatalf("playing a Bee should throw Poison Dart Frog's 2 rocks: %+v", rocks)
|
|
}
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
|
}
|
|
}
|
|
|
|
// Giant Isopod, once set aside, spends a Trumpet to feed each played pet.
|
|
func TestGiantIsopodFeedsOnPlay(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Giant Isopod"), g.pet("Ally", 1)},
|
|
[]Card{g.pet("K1", 4), g.pet("K2", 4), g.pet("Wall", 1)},
|
|
)
|
|
// Nyala banks 2 Trumpets, Isopod faints (set aside), then Ally enters and is
|
|
// fed 2 apples (spending 1 Trumpet), reaching power 3 to beat the Wall.
|
|
fed := false
|
|
for _, ev := range eventsOfType(res, "eat") {
|
|
if ev.Seat == 0 && ev.Bonus == 2 {
|
|
fed = true
|
|
}
|
|
}
|
|
if !fed {
|
|
t.Fatalf("Giant Isopod should feed Ally 2 apples: %+v", eventsOfType(res, "eat"))
|
|
}
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("fed Ally should win, got %d", res.WinnerSeat)
|
|
}
|
|
}
|
|
|
|
// Raccoon steals the enemy perk onto the top of its own deck.
|
|
func TestRaccoonStealsPerk(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenPet(t, "Raccoon")},
|
|
[]Card{g.realFood(t, "Melon"), g.pet("Foe", 2)},
|
|
)
|
|
stole := false
|
|
for _, ev := range eventsOfType(res, "summon") {
|
|
if ev.Card != nil && ev.Card.Name == "Melon" && ev.Seat == 0 {
|
|
stole = true
|
|
}
|
|
}
|
|
if !stole {
|
|
t.Fatalf("Raccoon should summon the stolen Melon onto its own deck: %+v", eventsOfType(res, "summon"))
|
|
}
|
|
}
|
|
|
|
// Macaque recycles its perk and apples onto the top of the deck.
|
|
func TestMacaqueRecyclesPerkAndApples(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
// Macaque wears Honey (a perk) and carries an apple, then faints.
|
|
res := forceBattle(t, g,
|
|
[]Card{g.realFood(t, "Honey"), g.newApple(), g.goldenPet(t, "Macaque")},
|
|
[]Card{g.pet("Big", 9)},
|
|
)
|
|
// On faint it puts the apple and the Honey perk back on top of the deck.
|
|
var sawApple, sawHoney bool
|
|
for _, ev := range eventsOfType(res, "summon") {
|
|
if ev.Card == nil || ev.Seat != 0 {
|
|
continue
|
|
}
|
|
switch ev.Card.Name {
|
|
case "Apple":
|
|
sawApple = true
|
|
case "Honey":
|
|
sawHoney = true
|
|
}
|
|
}
|
|
if !sawApple || !sawHoney {
|
|
t.Fatalf("Macaque should recycle its apple and Honey perk: %+v", eventsOfType(res, "summon"))
|
|
}
|
|
}
|
|
|
|
// Potato prevents 2 damage on each of the first two hits.
|
|
func TestPotatoPreventsTwice(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenFood(t, "Potato"), g.pet("Hero", 5)},
|
|
[]Card{g.pet("A", 3), g.pet("B", 3)},
|
|
)
|
|
prevents := eventsOfType(res, "prevent")
|
|
if len(prevents) != 2 {
|
|
t.Fatalf("Potato should prevent damage twice, got %d: %+v", len(prevents), prevents)
|
|
}
|
|
for _, p := range prevents {
|
|
if p.Count != 2 || p.Seat != 0 {
|
|
t.Fatalf("each prevention should shave 2 for seat 0: %+v", p)
|
|
}
|
|
}
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("Hero should survive both hits and win, got %d", res.WinnerSeat)
|
|
}
|
|
}
|
|
|
|
// Durian discards the enemy's apples in play.
|
|
func TestDurianStripsEnemyApples(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.goldenFood(t, "Durian"), g.pet("Hero", 3)},
|
|
[]Card{g.newApple(), g.newApple(), g.pet("Foe", 1)}, // Foe buffed to power 3
|
|
)
|
|
// Without the strip, a power-3 Foe would trade evenly; stripped back to
|
|
// power 1, it just dies.
|
|
if res.WinnerSeat != 0 {
|
|
t.Fatalf("Durian should strip Foe's apples so Hero wins, got %d", res.WinnerSeat)
|
|
}
|
|
if len(eventsOfType(res, "strip")) == 0 {
|
|
t.Fatal("expected a strip event")
|
|
}
|
|
}
|
|
|
|
// Saiga Antelope banks a Trumpet per friendly fainted pet on entry.
|
|
func TestSaigaTrumpetsPerFainted(t *testing.T) {
|
|
g, _, _ := testGame(t)
|
|
res := forceBattle(t, g,
|
|
[]Card{g.pet("F1", 1), g.pet("F2", 1), g.goldenPet(t, "Saiga Antelope")},
|
|
[]Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)},
|
|
)
|
|
// F1/F2 trade with K1/K2 (2 friendly faints), then Saiga banks 2 Trumpets.
|
|
var gain *BattleEvent
|
|
for i, ev := range res.Events {
|
|
if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 {
|
|
gain = &res.Events[i]
|
|
}
|
|
}
|
|
if gain == nil {
|
|
t.Fatalf("Saiga should bank 2 Trumpets for 2 friendly faints: %+v", eventsOfType(res, "trumpet"))
|
|
}
|
|
}
|
|
|
|
// --- Tier 4-5 shop mechanics ---
|
|
|
|
// Manta Ray waives the cost of the first buy each round (with few pets).
|
|
func TestMantaRayFirstBuyFree(t *testing.T) {
|
|
g, p1, _ := goldenGame(t)
|
|
p1.Deck = append(p1.Deck, g.goldenPet(t, "Manta Ray"))
|
|
g.startShopRound() // re-open the shop with Manta Ray in the deck
|
|
if !p1.FirstBuyFree {
|
|
t.Fatal("Manta Ray should ready a free first buy at 4 or fewer pets")
|
|
}
|
|
g.Turn = 0
|
|
coins := p1.Coins
|
|
g.ShopRow[0] = g.goldenPet(t, "Nyala")
|
|
if err := g.Buy(p1.ID, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p1.Coins != coins {
|
|
t.Fatalf("the first buy should be free: coins %d -> %d", coins, p1.Coins)
|
|
}
|
|
if p1.FirstBuyFree {
|
|
t.Fatal("the free-buy flag should clear after use")
|
|
}
|
|
// The next buy costs a coin.
|
|
g.Turn = 0
|
|
if err := g.Buy(p1.ID, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p1.Coins != coins-1 {
|
|
t.Fatalf("the second buy should cost a coin: %d", p1.Coins)
|
|
}
|
|
}
|
|
|
|
// Blue-Ringed Octopus grants an apple per buy made this round.
|
|
func TestOctopusApplesPerBuy(t *testing.T) {
|
|
g, p1, _ := goldenGame(t)
|
|
g.Turn = 0
|
|
g.ShopRow[0] = g.goldenPet(t, "Nyala") // a plain first buy
|
|
if err := g.Buy(p1.ID, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g.Turn = 0
|
|
g.ShopRow[1] = g.goldenPet(t, "Blue-Ringed Octopus") // the second buy
|
|
if err := g.Buy(p1.ID, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Second buy: buysThisRound == 2 -> 2 apples.
|
|
if countApplesIn(p1.Deck) != 2 {
|
|
t.Fatalf("Octopus bought as the 2nd buy should add 2 apples, got %d", countApplesIn(p1.Deck))
|
|
}
|
|
}
|
|
|
|
// Cockatoo makes the buyer reveal a pet for apples equal to its power.
|
|
func TestCockatooReveal(t *testing.T) {
|
|
g, p1, _ := goldenGame(t)
|
|
beefy := g.pet("Beefy", 5)
|
|
p1.Deck = append(p1.Deck, beefy)
|
|
g.Turn = 0
|
|
g.ShopRow[0] = g.goldenPet(t, "Cockatoo")
|
|
if err := g.Buy(p1.ID, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.PendingReveal == nil || g.PendingReveal.PlayerID != p1.ID {
|
|
t.Fatalf("buying Cockatoo should open a reveal: %+v", g.PendingReveal)
|
|
}
|
|
if g.Turn != 0 {
|
|
t.Fatalf("the turn should stay on the buyer during the reveal, got %d", g.Turn)
|
|
}
|
|
if err := g.RevealChoose(p1.ID, beefy.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if g.PendingReveal != nil {
|
|
t.Fatal("the reveal should be resolved")
|
|
}
|
|
if countApplesIn(p1.Deck) != 5 {
|
|
t.Fatalf("revealing a power-5 pet should add 5 apples, got %d", countApplesIn(p1.Deck))
|
|
}
|
|
}
|