Record each seat's arranged lineup on the battle result (already public) and reveal the full deck in a popover when hovering a deck pile in the battle view — the opponent's included.
717 lines
23 KiB
Go
717 lines
23 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])
|
|
}
|
|
}
|