Fix a bunch of bugs.

This commit is contained in:
Greyson Parrelli
2026-07-24 09:51:25 -04:00
parent ceab30dc2e
commit 094f38593e
8 changed files with 308 additions and 79 deletions
+20 -6
View File
@@ -671,15 +671,19 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
}
}
// hurt fires Hurt effects on a pet that was damaged and survived.
// hurt fires Hurt effects on a pet that took damage. It fires even when the
// hit was fatal — the Lizard drops its Bee even when killed in one shot —
// except for self-buffs marked SurviveOnly (Peacock, Gorilla), which need
// the pet to live on.
hurt := func(seat int, u *BattleUnit) {
if !u.Alive() {
return
}
alive := u.Alive()
for _, e := range u.effects() {
if e.Trigger != TriggerHurt || !allowed(e, u) {
continue
}
if !alive && e.SurviveOnly {
continue
}
if !spend(seat, e, u.Card.Name) {
continue
}
@@ -787,6 +791,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
emitPrevent(target, tu.Card.Name, prev)
}
if died {
// Hurt still fires on a fatal hit (Lizard's Bee), before the faint.
if dealt > 0 {
hurt(target, tu)
}
faint(target, tu)
sides[target].unit = nil
return true
@@ -994,8 +1002,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
// when its own side is already out (Crocodile's last-pet volley).
anyDeath := false
for _, q := range plays {
// Effects sourced from a specific unit fizzle if it's gone.
if q.unit != nil && sides[q.seat].unit != q.unit {
// Effects sourced from a specific unit fizzle if it's gone — unless
// they're posthumous (the Manatee still adds its Apples after rocking
// itself to death).
if q.unit != nil && sides[q.seat].unit != q.unit && !q.effect.Posthumous {
continue
}
if q.unit != nil && !allowed(q.effect, q.unit) {
@@ -1293,6 +1303,10 @@ func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
dealt := []int{dealtA, dealtB}
for seat, u := range []*BattleUnit{ua, ub} {
if !u.Alive() {
// Hurt still fires on a fatal clash (Lizard's Bee), before the faint.
if dealt[seat] > 0 {
hurt(seat, u)
}
faint(seat, u)
sides[seat].unit = nil
} else {
+43 -4
View File
@@ -141,6 +141,38 @@ func TestBattleDamageMarkers(t *testing.T) {
}
}
// A Hurt effect still fires on a fatal hit: the Lizard drops a Bee on top of
// its deck even when killed in one clash.
func TestLizardBeeOnFatalHit(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.goldenPet(t, "Lizard")},
[]Card{g.pet("Wall", 20)}, // one clash kills the 2-power Lizard
)
bees := 0
for _, ev := range eventsOfType(res, "summon") {
if ev.Card != nil && ev.Card.Name == "Bee" {
bees++
}
}
if bees == 0 {
t.Fatal("Lizard should add a Bee even when killed in one hit")
}
}
// A Hurt effect marked SurviveOnly does NOT fire on a fatal hit: the Peacock's
// "if this pet hasn't fainted" apple is skipped when the clash kills it.
func TestPeacockNoEatOnFatalHit(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Peacock")},
[]Card{g.pet("Wall", 20)}, // one clash kills the 2-power Peacock
)
if len(eventsOfType(res, "eat")) != 0 {
t.Fatal("Peacock should not eat an apple when it faints")
}
}
func TestBattleEqualPowerBothDie(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
@@ -486,7 +518,8 @@ func TestDolphinThrowsThreeRocks(t *testing.T) {
}
}
// Camel pushes an apple onto its own stack whenever it's hurt and survives.
// Camel pushes an apple onto its own stack whenever it's hurt — including the
// hit that finishes it, since Hurt now fires on faint.
func TestCamelHurtSummonsApple(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
@@ -494,10 +527,16 @@ func TestCamelHurtSummonsApple(t *testing.T) {
[]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.
// Chip2: camel dies but its Hurt still fires → a second apple. A reveals
// both apples onto Ally 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 len(summons) != 2 {
t.Fatalf("camel should summon an apple each time it's hurt (twice): %+v", summons)
}
for _, s := range summons {
if s.Seat != 0 || s.Card.Food != FoodApple {
t.Fatalf("camel apples should land on its own stack: %+v", s)
}
}
if res.WinnerSeat != 0 {
t.Fatalf("apple-buffed ally should win, got %d", res.WinnerSeat)
+12 -3
View File
@@ -258,6 +258,15 @@ type Effect struct {
// effect to fire (Golden pack). It's auto-paid when affordable and the
// effect is skipped otherwise — battles take no player input.
CostTrumpet int `json:"costTrumpet,omitempty"`
// SurviveOnly marks a Hurt effect that fires only when the pet lives through
// the hit. By default a Hurt effect still fires on a fatal hit (the Lizard
// drops its Bee even when killed); self-buffs that are pointless once the
// pet is gone (Peacock's apple, Gorilla's shield) opt out with this.
SurviveOnly bool `json:"surviveOnly,omitempty"`
// Posthumous marks a Play effect that still resolves even after its own pet
// has left play — so the Manatee still adds its Apples after rocking itself
// to death.
Posthumous bool `json:"posthumous,omitempty"`
}
// count normalizes the zero value to 1.
@@ -361,7 +370,7 @@ var petTiers = [MaxRounds][]petTemplate{
},
{
Name: "Peacock", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple}},
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionEatApple, SurviveOnly: true}},
EffectText: "Hurt: if this pet hasn't fainted, it eats 1 Apple",
},
{
@@ -500,7 +509,7 @@ var petTiers = [MaxRounds][]petTemplate{
{ // Tier 6
{
Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf}},
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf, SurviveOnly: true}},
EffectText: "Hurt: the next time this pet is hit, prevent all damage",
},
{
@@ -727,7 +736,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{
Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"},
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4},
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4, Posthumous: true},
},
EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck",
},
+29
View File
@@ -136,6 +136,35 @@ func TestManateeSelfRock(t *testing.T) {
}
}
// 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)
}
}
// Poison Dart Frog, once set aside, throws rocks each time a Bee is played.
func TestPoisonDartFrogBeeRocks(t *testing.T) {
g, _, _ := testGame(t)