From 816ad13f7ebaad17fb4dce9a04b6343484e9796c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 11:17:56 -0400 Subject: [PATCH] Improve rock attribution. --- internal/game/battle.go | 57 +++++++++++++++++++++++------------ internal/game/tier456_test.go | 6 ++++ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/internal/game/battle.go b/internal/game/battle.go index 8e9c58f..768aa76 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -172,7 +172,7 @@ type battleSide struct { petBonus int // Mammoth aura: +power for later friendly pets oneShotRocks []setAsideRocks // Badger/Blowfish: on next own pet play - recurringRocks []int // Snake: on every own pet play + recurringRocks []setAsideRocks // Snake: on every own pet play lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet shieldCards []Card // Turtle set-aside cards, parallel to shields } @@ -194,6 +194,7 @@ type queuedPlay struct { effect Effect everyone bool // rock volley hits every active pet (Badger) release *Card // set-aside card to release once this play resolves (Blowfish/Badger/Croc) + source *Card // set-aside pet credited as the effect's source (Blowfish/Badger/Croc/Snake) } // effectCount resolves an effect's final count: base × Per statistic, @@ -389,7 +390,8 @@ func (g *Game) resolveBattle() { setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card}) setAside() case ActionRecurringRocks: - s.recurringRocks = append(s.recurringRocks, e.count()) + s.recurringRocks = append(s.recurringRocks, + setAsideRocks{dice: e.count(), src: u.Card}) setAside() case ActionEnemyLastPetRocks: s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card}) @@ -457,7 +459,7 @@ func (g *Game) resolveBattle() { // throwRocks rolls `dice` rock dice against one seat's pet. Rocks are // not "attacks with" the pet, so no knockout applies. Reports a kill. - throwRocks := func(from, target, dice int) (killed bool) { + throwRocks := func(from, target, dice int, source *Card) (killed bool) { tu := sides[target].unit if tu == nil { return false @@ -470,18 +472,32 @@ func (g *Game) resolveBattle() { } dealt, block := hitUnit(target, roll) died := !tu.Alive() - thrower := pname(from) - if su := sides[from].unit; su != nil { - thrower = su.Card.Name - } + // A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a + // different pet has been played, so credit `source` explicitly; only + // fall back to the pet in play for direct throwers (Hedgehog/Rhino). var rockTxt string - switch { - case roll == 0: - rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name) - case died: - rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll) - default: - rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll) + if source != nil { + switch { + case roll == 0: + rockTxt = fmt.Sprintf("%s's set-aside rocks miss %s.", source.Name, tu.Card.Name) + case died: + rockTxt = fmt.Sprintf("%s's set-aside rocks pelt %s for %d — it faints.", source.Name, tu.Card.Name, roll) + default: + rockTxt = fmt.Sprintf("%s's set-aside rocks pelt %s for %d.", source.Name, tu.Card.Name, roll) + } + } else { + thrower := pname(from) + if su := sides[from].unit; su != nil { + thrower = su.Card.Name + } + switch { + case roll == 0: + rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name) + case died: + rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll) + default: + rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll) + } } emit(BattleEvent{ Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, @@ -561,12 +577,13 @@ func (g *Game) resolveBattle() { for _, r := range s.oneShotRocks { src := r.src plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone, - effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src}) + effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src, source: &src}) } s.oneShotRocks = nil - for _, dice := range s.recurringRocks { + for _, r := range s.recurringRocks { + src := r.src plays = append(plays, queuedPlay{seat: seat, - effect: Effect{Action: ActionThrowRock, Count: dice}}) + effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src}) } // Walk the pet's own play effects, then its active perk's, so a // play-time shield knows its source (an innate pet block vs a @@ -622,7 +639,7 @@ func (g *Game) resolveBattle() { for _, v := range os.lastPetRocks { src := v.src plays = append(plays, queuedPlay{seat: other, - effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src}) + effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src, source: &src}) } os.lastPetRocks = nil } @@ -669,12 +686,12 @@ func (g *Game) resolveBattle() { } if q.everyone { for seat := range sides { - if throwRocks(q.seat, seat, dice) { + if throwRocks(q.seat, seat, dice, q.source) { anyDeath = true } } } else if t := nextTarget(q.seat); t >= 0 { - if throwRocks(q.seat, t, dice) { + if throwRocks(q.seat, t, dice, q.source) { anyDeath = true } } diff --git a/internal/game/tier456_test.go b/internal/game/tier456_test.go index 6fa09a9..0b76355 100644 --- a/internal/game/tier456_test.go +++ b/internal/game/tier456_test.go @@ -588,6 +588,12 @@ func TestSnakeRecurringRocks(t *testing.T) { if len(rocks) != 2 { t.Fatalf("snake should volley on each friendly play: %+v", rocks) } + // The set-aside Snake threw the rocks, not the pet (A/B) in play. + for _, r := range rocks { + if !strings.Contains(r.Text, "Snake") { + t.Fatalf("rock event should credit Snake as the source, got %q", r.Text) + } + } if res.WinnerSeat != -1 { t.Fatalf("expected draw, got %d", res.WinnerSeat) }