Files
super-auto-pets-board-game/internal/game/battle_test.go
T

739 lines
24 KiB
Go

package game
import (
"slices"
"testing"
)
// testGame builds a started 2-player game without going through the lobby.
func testGame(t *testing.T) (*Game, *Player, *Player) {
t.Helper()
g := New()
p1, err := g.AddPlayer("Alice")
if err != nil {
t.Fatal(err)
}
p2, err := g.AddPlayer("Bob")
if err != nil {
t.Fatal(err)
}
if g.Phase != PhaseShop {
t.Fatalf("expected shop phase after both players join, got %s", g.Phase)
}
// Pin the (otherwise random) priority token to seat 0 so tests are
// deterministic: seat 0 shops first and wins battle simultaneity races.
g.PrioritySeat = p1.Seat
g.Turn = p1.Seat
return g, p1, p2
}
// pet mints a plain effect-less pet for battle scenarios.
func (g *Game) pet(name string, power int) Card {
return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: SuitRed}
}
// realPet mints a copy of a real pet (with its effects) by name, searching
// all tiers.
func (g *Game) realPet(t *testing.T, name string) Card {
t.Helper()
for tierIdx, tier := range petTiers {
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 pet named %s", name)
return Card{}
}
// tier1 is kept as a shorthand for realPet.
func (g *Game) tier1(t *testing.T, name string) Card { return g.realPet(t, name) }
// realFood mints a shop food card (Honey, Garlic, ...) from its template.
func (g *Game) realFood(t *testing.T, name string) Card {
t.Helper()
for tierIdx, tier := range foodTiers {
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 food named %s", name)
return Card{}
}
func (g *Game) newHoney(t *testing.T) Card { return g.realFood(t, "Honey") }
// forceBattle sets both decks, arranges them in current order, and resolves.
func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
t.Helper()
g.Players[0].Deck = d1
g.Players[1].Deck = d2
g.Phase = PhaseArrange
g.Players[0].Ready = false
g.Players[1].Ready = false
for _, p := range g.Players {
ids := make([]string, len(p.Deck))
for i, c := range p.Deck {
ids[i] = c.ID
}
if err := g.SubmitOrder(p.ID, ids); err != nil {
t.Fatal(err)
}
}
if g.Phase != PhaseBattle {
t.Fatalf("expected battle phase, got %s", g.Phase)
}
return g.Battle
}
func eventsOfType(res *BattleResult, typ string) []BattleEvent {
var out []BattleEvent
for _, ev := range res.Events {
if ev.Type == typ {
out = append(out, ev)
}
}
return out
}
// The worked example from the rules: a 3-power pet fights a 5-power pet. The
// 3 dies, the 5 survives with 3 damage markers (2 health left, 5 attack).
// Then a 2-power pet trades with it: both die.
func TestBattleDamageMarkers(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Three", 3), g.pet("Two", 2)},
[]Card{g.pet("Five", 5)},
)
clashes := eventsOfType(res, "clash")
if len(clashes) != 2 {
t.Fatalf("expected 2 clashes, got %d", len(clashes))
}
first := clashes[0]
if !first.Died[0] || first.Died[1] {
t.Fatalf("first clash: 3-power should die, 5-power should survive: %+v", first)
}
if first.Damage[1] != 3 {
t.Fatalf("5-power pet should carry 3 damage, has %d", first.Damage[1])
}
second := clashes[1]
if !second.Died[0] || !second.Died[1] {
t.Fatalf("second clash: both should die (5 attack kills the 2; 3+2 damage kills the 5): %+v", second)
}
if res.WinnerSeat != -1 {
t.Fatalf("battle should be a draw, winner=%d", res.WinnerSeat)
}
if g.Players[0].Trophies != 0 || g.Players[1].Trophies != 0 {
t.Fatal("no trophies on a draw")
}
}
func TestBattleEqualPowerBothDie(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("A", 4)},
[]Card{g.pet("B", 4)},
)
clashes := eventsOfType(res, "clash")
if len(clashes) != 1 || !clashes[0].Died[0] || !clashes[0].Died[1] {
t.Fatalf("equal power pets should both die: %+v", clashes)
}
if res.WinnerSeat != -1 {
t.Fatal("expected a draw")
}
}
func TestBattleWinnerGetsTrophy(t *testing.T) {
g, _, p2 := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Small", 1)},
[]Card{g.pet("Big", 5)},
)
if res.WinnerSeat != 1 {
t.Fatalf("seat 1 should win, got %d", res.WinnerSeat)
}
if res.Trophies != 1 || p2.Trophies != 1 {
t.Fatalf("round 1 win should award 1 trophy, got %d/%d", res.Trophies, p2.Trophies)
}
}
func TestBattleFinalRoundWorthTwoTrophies(t *testing.T) {
g, _, p2 := testGame(t)
g.Round = MaxRounds
res := forceBattle(t, g,
[]Card{g.pet("Small", 1)},
[]Card{g.pet("Big", 5)},
)
if res.Trophies != 2 || p2.Trophies != 2 {
t.Fatalf("final round win should award 2 trophies, got %d/%d", res.Trophies, p2.Trophies)
}
}
// Foods attach to the next pet revealed beneath them; each apple adds 1
// power. 3+2 apples = 5 power draws with a plain 5.
func TestBattleApplesBuffNextPet(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.newApple(), g.newApple(), g.pet("Buffed", 3)},
[]Card{g.pet("Enemy", 5)},
)
clash := eventsOfType(res, "clash")[0]
if !clash.Died[0] || !clash.Died[1] {
t.Fatalf("5 vs 3+2apples should kill both: %+v", clash)
}
if res.WinnerSeat != -1 {
t.Fatal("expected draw")
}
}
// A side whose stack holds only foods can't field a pet and loses without a
// single clash.
func TestBattleFoodOnlyLoses(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Solo", 1)},
[]Card{g.newApple()},
)
if len(eventsOfType(res, "clash")) != 0 {
t.Fatal("expected no clashes")
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win by default, got %d", res.WinnerSeat)
}
}
// Ant's faint puts an apple on top of its owner's stack, buffing the next
// pet revealed: Ant(1)+Follower(2) beats a plain 2.
func TestAntFaintAddsApple(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.tier1(t, "Ant"), g.pet("Follower", 2)},
[]Card{g.pet("Enemy", 2)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple {
t.Fatalf("ant faint should summon an apple for seat 0: %+v", summons)
}
// Follower fights at 3 power vs enemy at 2 power with 1 damage: enemy
// dies, follower survives (2 damage < 3 power).
if res.WinnerSeat != 0 {
t.Fatalf("apple-buffed follower should win, got seat %d", res.WinnerSeat)
}
}
// Cricket's faint summons a bee, which fights as a real 1-power pet.
func TestCricketFaintSummonsBee(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.tier1(t, "Cricket")},
[]Card{g.pet("Enemy", 3)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 1 || summons[0].Card.Name != "Bee" {
t.Fatalf("cricket faint should summon a bee: %+v", summons)
}
beeRevealed := false
for _, ev := range eventsOfType(res, "reveal") {
if ev.Card.Name == "Bee" {
beeRevealed = true
}
}
if !beeRevealed {
t.Fatal("the summoned bee should be revealed and fight")
}
// Cricket (1) then Bee (1) chip the 3-power enemy for 2 total; enemy
// survives with 2 damage and wins.
if res.WinnerSeat != 1 {
t.Fatalf("enemy should survive with 2 damage, got winner %d", res.WinnerSeat)
}
if len(eventsOfType(res, "clash")) != 2 {
t.Fatal("expected two clashes: cricket then bee")
}
}
// Mosquito's rock lands before the clash and can kill outright: with a
// rigged roll of 1, a 1-power enemy dies to the rock and the mosquito never
// takes damage.
func TestMosquitoRockKillsBeforeClash(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.tier1(t, "Mosquito")},
[]Card{g.pet("Weakling", 1)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Seat != 0 || rocks[0].Target != 1 {
t.Fatalf("expected one rock from seat 0 at seat 1: %+v", rocks)
}
if rocks[0].Roll != 1 || !rocks[0].TargetDied || rocks[0].DamageAfter != 1 {
t.Fatalf("rock should kill the 1-power pet: %+v", rocks[0])
}
if len(eventsOfType(res, "clash")) != 0 {
t.Fatal("no clash should happen; the rock already won it")
}
if res.WinnerSeat != 0 {
t.Fatalf("mosquito should win, got %d", res.WinnerSeat)
}
}
// A rock can roll blank faces: 0 damage, no effect, then a normal clash.
func TestMosquitoRockCanMiss(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 0 }
res := forceBattle(t, g,
[]Card{g.tier1(t, "Mosquito")},
[]Card{g.pet("Weakling", 1)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 0 || rocks[0].TargetDied || rocks[0].DamageAfter != 0 {
t.Fatalf("blank roll should do nothing: %+v", rocks)
}
if len(eventsOfType(res, "clash")) != 1 {
t.Fatal("the clash should still happen after a miss")
}
if res.WinnerSeat != 0 {
t.Fatalf("mosquito still wins the clash 2v1, got %d", res.WinnerSeat)
}
}
// Two mosquitos rock each other simultaneously (roll 1 each); both survive
// (2 power, 1 damage), then clash and both die.
func TestMosquitoMirror(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.tier1(t, "Mosquito")},
[]Card{g.tier1(t, "Mosquito")},
)
if len(eventsOfType(res, "rock")) != 2 {
t.Fatal("both mosquitos should throw rocks")
}
clash := eventsOfType(res, "clash")[0]
if !clash.Died[0] || !clash.Died[1] {
t.Fatalf("both wounded mosquitos should die in the clash: %+v", clash)
}
if res.WinnerSeat != -1 {
t.Fatal("expected a draw")
}
}
// Flamingo's faint stacks two apples: the follower fights at +2.
func TestFlamingoFaintAddsTwoApples(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Flamingo"), g.pet("Follower", 2)},
[]Card{g.pet("Enemy", 3)},
)
if len(eventsOfType(res, "summon")) != 2 {
t.Fatalf("flamingo should summon 2 apples: %+v", eventsOfType(res, "summon"))
}
// Follower at 4 power vs enemy at 3 power carrying 1 damage: enemy
// dies, follower survives (3 damage < 4 power).
if res.WinnerSeat != 0 {
t.Fatalf("buffed follower should win, got %d", res.WinnerSeat)
}
}
// Peacock eats an apple every time it's hurt and survives.
func TestPeacockEatsWhenHurt(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Peacock")}, // 2 power
[]Card{g.pet("Chip", 1), g.pet("Chip2", 1)},
)
// Clash 1: peacock takes 1 (alive at 2 power), eats → 3 power.
// Clash 2: takes 1 more (2 damage < 3 power), eats → 4 power.
eats := eventsOfType(res, "eat")
if len(eats) != 2 {
t.Fatalf("peacock should eat twice, got %+v", eats)
}
if eats[0].Bonus != 1 || eats[1].Bonus != 2 {
t.Fatalf("bonus should grow 1 then 2: %+v", eats)
}
if res.WinnerSeat != 0 {
t.Fatalf("peacock should survive and win, got %d", res.WinnerSeat)
}
}
// Rat's faint puts a bee on the ENEMY's stack, where it fights for them.
func TestRatFaintSummonsBeeForEnemy(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Rat"), g.pet("Closer", 9)}, // rat: 4 power
[]Card{g.pet("Equal", 4)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 1 || summons[0].Seat != 1 || summons[0].Card.Name != "Bee" {
t.Fatalf("rat should summon a bee on the enemy stack: %+v", summons)
}
// Rat and Equal trade (both die). The bee fights for seat 1 next and
// loses to the Closer.
beeRevealedBySeat1 := false
for _, ev := range eventsOfType(res, "reveal") {
if ev.Card.Name == "Bee" && ev.Seat == 1 {
beeRevealedBySeat1 = true
}
}
if !beeRevealedBySeat1 {
t.Fatal("the enemy should reveal and field the rat's bee")
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Spider pushes a bee then an apple: the apple ends up on top, so it buffs
// the bee to 2 power.
func TestSpiderFaintBeeGetsApple(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Spider")}, // 2 power
[]Card{g.pet("Enemy", 3)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 2 || summons[0].Card.Name != "Bee" || summons[1].Card.Food != FoodApple {
t.Fatalf("spider should summon bee then apple: %+v", summons)
}
// Enemy (3 power) takes 2 from spider, then fights the 2-power bee
// (1+apple): both die. Draw.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Honey is a perk: the pet it's attached to summons a bee when it faints,
// and only the last-applied perk counts.
func TestHoneyPerk(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.newHoney(t), g.newHoney(t), g.pet("Bear", 2)},
[]Card{g.pet("Enemy", 3)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 1 || summons[0].Card.Name != "Bee" || summons[0].Seat != 0 {
t.Fatalf("exactly one honey (the last-applied) should trigger: %+v", summons)
}
// Bear dies to the 3; enemy carries 2 damage; the bee finishes it and
// dies too: draw.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Garlic prevents 1 damage from every attack that hits its pet.
func TestGarlicPreventsDamage(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realFood(t, "Garlic"), g.pet("Tank", 2)},
[]Card{g.pet("Equal", 2)},
)
// Tank takes 2-1=1 (survives at 2 power); Equal takes 2 and dies.
clash := eventsOfType(res, "clash")[0]
if clash.Died[0] || !clash.Died[1] {
t.Fatalf("garlic tank should survive the equal-power clash: %+v", clash)
}
if clash.Damage[0] != 1 {
t.Fatalf("garlic should reduce the hit to 1, got %d", clash.Damage[0])
}
if res.WinnerSeat != 0 {
t.Fatalf("garlic side should win, got %d", res.WinnerSeat)
}
}
// Two garlic'd 1-power pets can never hurt each other: the battle must end
// as a stalemate draw instead of looping forever.
func TestGarlicStalemateIsDraw(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realFood(t, "Garlic"), g.pet("A", 1)},
[]Card{g.realFood(t, "Garlic"), g.pet("B", 1)},
)
if res.WinnerSeat != -1 {
t.Fatalf("stalemate should be a draw, got %d", res.WinnerSeat)
}
if len(res.Events) > 10 {
t.Fatalf("stalemate should end immediately, got %d events", len(res.Events))
}
}
// Dolphin throws 3 rocks on play (rigged to roll 1 each = 3 damage).
func TestDolphinThrowsThreeRocks(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin")},
[]Card{g.pet("Tank", 3)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 3 || !rocks[0].TargetDied {
t.Fatalf("dolphin should roll 3 dice for 3 damage and kill the tank: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("dolphin should win, got %d", res.WinnerSeat)
}
}
// Camel pushes an apple onto its own stack whenever it's hurt and survives.
func TestCamelHurtSummonsApple(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Camel"), g.pet("Ally", 2)}, // camel: 3 power
[]Card{g.pet("Chip", 1), g.pet("Chip2", 3)},
)
// Clash 1: camel takes 1 (survives) → apple onto A's stack. Clash 2 vs
// Chip2: both die. A reveals apple + Ally (3 power) and wins.
summons := eventsOfType(res, "summon")
if len(summons) != 1 || summons[0].Seat != 0 || summons[0].Card.Food != FoodApple {
t.Fatalf("camel should summon one apple onto its own stack: %+v", summons)
}
if res.WinnerSeat != 0 {
t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat)
}
}
// Sheep faints into two bees that fight on.
func TestSheepFaintSummonsTwoBees(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Sheep")}, // 3 power
[]Card{g.pet("Big", 5)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 2 || summons[0].Card.Name != "Bee" || summons[1].Card.Name != "Bee" {
t.Fatalf("sheep should summon 2 bees: %+v", summons)
}
// Big (5) takes 3 from sheep, then 1 from each bee: 5 total = dead; the
// second bee dies with it. Draw.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Dodo recycles up to 3 of its attached apples onto the deck when it faints.
func TestDodoRecyclesApples(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.newApple(), g.newApple(), g.realPet(t, "Dodo"), g.pet("Heir", 5)}, // dodo: 3+2=5
[]Card{g.pet("Big", 6)},
)
// Dodo (5) dies to the 6; Big carries 5 damage. The two apples come
// back on top: Heir fights at 5+2=7, survives Big's 6 attack, kills it.
summons := eventsOfType(res, "summon")
if len(summons) != 2 {
t.Fatalf("dodo should recycle exactly its 2 apples: %+v", summons)
}
for _, s := range summons {
if s.Seat != 0 || s.Card.Food != FoodApple {
t.Fatalf("recycled cards should be seat 0 apples: %+v", s)
}
}
if res.WinnerSeat != 0 {
t.Fatalf("recycled apples should carry the win, got %d", res.WinnerSeat)
}
}
// Dog eats one apple per friendly bee that has fainted this battle.
func TestDogEatsPerFaintedBee(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.tier1(t, "Cricket"), g.realPet(t, "Dog")}, // cricket 1, dog 2
[]Card{g.pet("Chip", 1), g.pet("Wall", 2)},
)
// Cricket and Chip trade; cricket's bee then dies to Wall (1 friendly
// bee fainted, Wall at 1 damage). Dog plays, eats 1 apple → 3 power,
// and beats the wounded Wall.
eats := eventsOfType(res, "eat")
if len(eats) != 1 || eats[0].Seat != 0 || eats[0].Bonus != 1 {
t.Fatalf("dog should eat exactly 1 apple: %+v", eats)
}
if res.WinnerSeat != 0 {
t.Fatalf("dog should win, got %d", res.WinnerSeat)
}
}
// Badger sets aside on faint; when the next friendly pet plays it throws 2
// rocks at EACH active pet — enemy and friend alike.
func TestBadgerDelayedRocksHitBothSides(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Badger"), g.pet("Next", 3)}, // badger: 3
[]Card{g.pet("Tank", 5)},
)
// Clash: badger dies (takes 5), Tank keeps 3 damage (2 health). Next
// plays → badger throws 2 rocks (2 damage) at Next AND at Tank: Next
// drops to 1 health, Tank dies at 5 damage. Seat 0 wins.
rocks := eventsOfType(res, "rock")
if len(rocks) != 2 {
t.Fatalf("badger should produce one rock volley per active pet: %+v", rocks)
}
hitSelf, hitEnemy := false, false
for _, r := range rocks {
if r.Roll != 2 {
t.Fatalf("each volley should roll 2 dice = 2 damage: %+v", r)
}
if r.Target == 0 {
hitSelf = true
if r.TargetDied {
t.Fatalf("Next (3 power) should survive 2 self-damage: %+v", r)
}
}
if r.Target == 1 {
hitEnemy = true
if !r.TargetDied {
t.Fatalf("Tank (5 power, 3 damage) should die to 2 more: %+v", r)
}
}
}
if !hitSelf || !hitEnemy {
t.Fatalf("badger must hit both sides: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Temporary cards (apples, bees) leave the deck once the battle is
// acknowledged; pets stay.
func TestTemporariesExpireAfterBattle(t *testing.T) {
g, p1, p2 := testGame(t)
forceBattle(t, g,
[]Card{g.newApple(), g.pet("Keeper", 3)},
[]Card{g.pet("Enemy", 1)},
)
if err := g.AcknowledgeBattle(p1.ID); err != nil {
t.Fatal(err)
}
if err := g.AcknowledgeBattle(p2.ID); err != nil {
t.Fatal(err)
}
if slices.ContainsFunc(p1.Deck, func(c Card) bool { return c.Temporary }) {
t.Fatalf("temporary cards should be gone after the battle: %+v", p1.Deck)
}
if len(p1.Deck) != 1 || p1.Deck[0].Name != "Keeper" {
t.Fatalf("pets should survive the round: %+v", p1.Deck)
}
}
// TestPriorityTokenInitialAssignment checks the token starts on a real seat
// and that seat opens the shop.
func TestPriorityTokenInitialAssignment(t *testing.T) {
g := New()
if _, err := g.AddPlayer("Alice"); err != nil {
t.Fatal(err)
}
if _, err := g.AddPlayer("Bob"); err != nil {
t.Fatal(err)
}
if g.PrioritySeat < 0 || g.PrioritySeat >= len(g.Players) {
t.Fatalf("priority token should start on a real seat, got %d", g.PrioritySeat)
}
if g.Turn != g.PrioritySeat {
t.Fatalf("priority holder should open the shop: turn=%d priority=%d", g.Turn, g.PrioritySeat)
}
}
// TestPriorityWinsRockRace: two pets that both throw rocks on play would each
// kill the other, so whoever has priority throws first and survives. Flipping
// the token flips the winner, proving the token — not seat order — decides.
func TestPriorityWinsRockRace(t *testing.T) {
for _, priority := range []int{0, 1} {
g, _, _ := testGame(t)
g.RollDie = func() int { return 2 } // 3 dice = 6, lethal to a 2-power pet
g.PrioritySeat = priority
res := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin")},
[]Card{g.realPet(t, "Dolphin")})
if res.WinnerSeat != priority {
t.Fatalf("priority seat %d should win the rock race, winner=%d", priority, res.WinnerSeat)
}
}
}
// TestPriorityTokenTransfer: a winner who holds the token hands it to the
// loser; a loser who holds it keeps it; a draw leaves it put.
func TestPriorityTokenTransfer(t *testing.T) {
// Winner holds it -> passes to the loser.
g, _, _ := testGame(t)
g.PrioritySeat = 0
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
if g.Battle.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat)
}
if g.PrioritySeat != 1 {
t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat)
}
// Loser holds it -> keeps it.
g, _, _ = testGame(t)
g.PrioritySeat = 1
forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)})
if g.Battle.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat)
}
if g.PrioritySeat != 1 {
t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat)
}
// Draw -> token stays put. Equal-power pets trade lethal blows at once.
g, _, _ = testGame(t)
g.PrioritySeat = 0
forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)})
if g.Battle.WinnerSeat != -1 {
t.Fatalf("mutual KO should draw, got %d", g.Battle.WinnerSeat)
}
if g.PrioritySeat != 0 {
t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat)
}
}
// TestBattleResultExposesLineups: both seats' arranged decks are recorded on
// the result so players can peek the whole matchup.
func TestBattleResultExposesLineups(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("A", 3), g.pet("B", 2)},
[]Card{g.pet("C", 4)})
if len(res.Lineups) != 2 {
t.Fatalf("expected a lineup per seat, got %d", len(res.Lineups))
}
if len(res.Lineups[0]) != 2 || res.Lineups[0][0].Name != "A" {
t.Fatalf("seat 0 lineup should mirror its arranged deck: %+v", res.Lineups[0])
}
if len(res.Lineups[1]) != 1 || res.Lineups[1][0].Name != "C" {
t.Fatalf("seat 1 lineup should mirror its arranged deck: %+v", res.Lineups[1])
}
}
// A Skunk that strips a buffed pet's apples must drop that pet below its
// damage markers so it faints on the spot, rather than lingering at what is
// effectively negative health.
func TestSkunkStripFaintsDebuffedPet(t *testing.T) {
g, _, _ := testGame(t)
// Seat 1 fields a 3-power pet wearing three apples (effectively 6 power).
buffed := []Card{g.newApple(), g.newApple(), g.newApple(), g.pet("Buffed", 3)}
// Seat 0's Ram (4 power) trades in first: Ram dies, Buffed survives with 4
// damage (still under its buffed 6 power). Then the Skunk strips the
// apples, dropping Buffed to 3 power < 4 damage — it must faint at once.
mine := []Card{g.pet("Ram", 4), g.realPet(t, "Skunk")}
res := forceBattle(t, g, mine, buffed)
strips := eventsOfType(res, "strip")
if len(strips) != 1 || !strips[0].TargetDied {
t.Fatalf("skunk strip should faint the de-buffed pet: %+v", strips)
}
if res.WinnerSeat != 0 {
t.Fatalf("skunk should win once the stripped pet faints, got %d", res.WinnerSeat)
}
}