diff --git a/internal/game/battle.go b/internal/game/battle.go
index 964d2f2..23426d4 100644
--- a/internal/game/battle.go
+++ b/internal/game/battle.go
@@ -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 {
diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go
index 3f741f6..f38db91 100644
--- a/internal/game/battle_test.go
+++ b/internal/game/battle_test.go
@@ -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)
diff --git a/internal/game/cards.go b/internal/game/cards.go
index 98dda1b..6b32593 100644
--- a/internal/game/cards.go
+++ b/internal/game/cards.go
@@ -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",
},
diff --git a/internal/game/golden45_test.go b/internal/game/golden45_test.go
index e27ef1d..877e707 100644
--- a/internal/game/golden45_test.go
+++ b/internal/game/golden45_test.go
@@ -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)
diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx
index 506af8c..bc40b63 100644
--- a/web/src/components/CardView.tsx
+++ b/web/src/components/CardView.tsx
@@ -206,6 +206,15 @@ export function CardView({
onMouseLeave={preview ? undefined : () => setHover(null)}
>
{previewEl}
+ {/* Power sits in a spiky burst badge in the top-left corner (pets only),
+ with any battle damage marker beside it. It's absolutely placed so it
+ doesn't eat into the room the name and ability text need. */}
+ {card.kind === 'pet' && (
+
}
)
diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx
index 59bb98c..a7bbe92 100644
--- a/web/src/components/ShopPhase.tsx
+++ b/web/src/components/ShopPhase.tsx
@@ -93,6 +93,17 @@ export function ShopPhase({ view, you, send }: Props) {
wasMyTurn.current = myTurn
}, [myTurn, view.pending, view.pendingReveal])
const overPets = you.petCount > view.maxPets
+
+ // Show every coin you started the round with, fading the spent ones rather
+ // than dropping them. Coins only fall within a round, so the highest count
+ // seen this round is the starting purse. Reset when the round changes.
+ const purse = useRef({ round: view.round, max: you.coins })
+ if (purse.current.round !== view.round) {
+ purse.current = { round: view.round, max: you.coins }
+ }
+ purse.current.max = Math.max(purse.current.max, you.coins)
+ const totalCoins = purse.current.max
+
const deck = you.deck ?? []
const opponent = view.players.find((p) => p.seat !== view.youSeat)
const pending = view.pending
@@ -235,16 +246,24 @@ export function ShopPhase({ view, you, send }: Props) {
)}
- {turnBanner &&
Your turn!
}
+ {turnBanner && (
+
+ Your turn!
+
+ )}
- {/* Coins as big golden discs above the buy row. */}
-
- {Array.from({ length: Math.max(you.coins, 0) }, (_, i) => (
-
- 🪙
-
- ))}
- {you.coins === 0 && out of gold}
+ {/* Coins as big golden discs above the buy row. Spent coins stay put but
+ grey out, so you can see what you started the round with. */}
+