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

1328 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game
import (
"fmt"
"slices"
)
// BattleUnit is a pet in play with its attached foods applied. Power
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
type BattleUnit struct {
Card Card `json:"card"`
Foods []Card `json:"foods,omitempty"`
Bonus int `json:"bonus"` // total power added by foods, eating, auras
Damage int `json:"damage"` // damage markers accumulated this battle
// Shields are this pet's own full-hit blocks, newest last. Each carries
// its source so the log can name it (Gorilla's innate block, or a Melon
// perk) and the client can drop the spent card.
Shields []shieldCharge
// afterAttackUsed gates once-per-battle After-Attack effects (Bulldog).
afterAttackUsed bool
// hitPrevent are this pet's own one-shot partial damage preventions
// (Potato perk: two charges of 2). Consumed before any side-level charge.
hitPrevent []int
}
// shieldCharge is one full-hit block a pet carries. source is the granting
// item's display name (e.g. "Melon"); an empty source is the pet's own innate
// shield (Gorilla). card, when set, is the food to remove from the play area
// once the charge is spent.
type shieldCharge struct {
source string
card *Card
}
// shieldBlock describes a hit that a shield just absorbed, for the caller to
// narrate and clean up: source names the shield (empty = innate), and release
// is the field card to remove — a Melon food or a set-aside Turtle — or nil.
type shieldBlock struct {
source string
release *Card
}
// preventInfo describes a partial damage prevention a hit just consumed (Cone
// Snail): amount is the damage shaved off, release is the set-aside card to
// remove from the board. The caller narrates it after its own hit event so the
// replay order stays correct.
type preventInfo struct {
amount int
release *Card
}
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
// activePerk returns the perk this unit benefits from: only the
// last-applied perk counts when several are attached.
func (u *BattleUnit) activePerk() *Card {
for i := len(u.Foods) - 1; i >= 0; i-- {
if u.Foods[i].Perk {
return &u.Foods[i]
}
}
return nil
}
// effects returns the unit's live effects: its own plus its active perk's.
func (u *BattleUnit) effects() []Effect {
effs := u.Card.Effects
if perk := u.activePerk(); perk != nil {
effs = append(append([]Effect(nil), effs...), perk.Effects...)
}
return effs
}
// prevention totals the unit's passive per-attack damage reduction (Garlic).
func (u *BattleUnit) prevention() int {
total := 0
for _, e := range u.effects() {
if e.Trigger == TriggerPassive && e.Action == ActionPreventDamage {
total += e.count()
}
}
return total
}
// hasKnockout reports whether the unit's clash attacks KO (Scorpion).
func (u *BattleUnit) hasKnockout() bool {
for _, e := range u.effects() {
if e.Trigger == TriggerPassive && e.Action == ActionKnockout {
return true
}
}
return false
}
// appleCount counts apples among the given foods.
func appleCount(foods []Card) int {
n := 0
for _, f := range foods {
if f.Food == FoodApple {
n++
}
}
return n
}
// isBee reports whether a card is a summoned Bee.
func isBee(c Card) bool { return c.IsPet() && c.Name == "Bee" }
// BattleEvent is one step of the battle, in order, for clients to animate.
type BattleEvent struct {
// "prep": Card starts the battle already in play for Seat (Monkey).
// "reveal": Seat flipped Card off their stack (food or pet).
// "summon": an effect put Card on top of Seat's stack.
// "mill": Card was discarded off the top of Seat's stack (Chili).
// "rock": Seat's pet threw rocks at Target's pet in play.
// "clash": the pets in play traded blows.
// "shield": Seat's pet blocked a hit entirely.
// "strip": Seat's pet discarded all of Target's pet's foods (Skunk).
// "steal": Seat's pet stole Count apples from Target's pet (Wolverine).
// "eat": Seat's pet ate apples; Bonus is its new total.
// "heal": Seat's pet healed; DamageAfter is its new damage total.
// "setaside": Card (a fainted pet) enters Seat's set-aside zone with a
// pending effect (Blowfish, Badger, Snake, Crocodile, Turtle,
// Turkey, Mammoth).
// "release": a set-aside Card left Seat's zone, its effect consumed
// (matched by Card.ID).
Type string `json:"type"`
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
// player) and dropping it makes the client read sides[undefined].
Seat int `json:"seat"`
Target int `json:"target"`
Card *Card `json:"card,omitempty"`
Count int `json:"count,omitempty"`
// clash: per-seat damage totals / deaths after the exchange.
Damage []int `json:"damage,omitempty"`
Died []bool `json:"died,omitempty"`
// rock: individual die faces rolled (each 0, 1, or 2), for the dice
// animation; Roll is their sum.
Dice []int `json:"dice,omitempty"`
// rock: dice total rolled; rock/strip/steal: target pet's fate.
Roll int `json:"roll"`
DamageAfter int `json:"damageAfter"`
TargetDied bool `json:"targetDied,omitempty"`
// eat: the pet's power bonus after eating.
Bonus int `json:"bonus"`
// Text is a human-readable description of this step for the event log,
// e.g. "Ant's faint effect summons a Bee." Empty for steps not worth a
// line (they still animate).
Text string `json:"text,omitempty"`
}
// BattleResult is the full, public record of one round's battle.
type BattleResult struct {
Round int `json:"round"`
StackSizes []int `json:"stackSizes"` // starting deck size per seat
// Lineups is each seat's arranged deck at battle start (top of deck
// first). Public so players can review the whole matchup — including the
// opponent's cards — during and after the fight.
Lineups [][]Card `json:"lineups,omitempty"`
Events []BattleEvent `json:"events"`
WinnerSeat int `json:"winnerSeat"` // -1 = draw
Trophies int `json:"trophies"` // awarded to the winner
}
// setAsideRocks is a fainted pet's pending rock payout.
type setAsideRocks struct {
dice int
everyone bool // Badger: hits every active pet, the owner's own included
src Card // the fainted pet, for the set-aside display
}
// lastPetVolley is Crocodile's set-aside: rocks that fire when the enemy plays
// the last pet in their deck.
type lastPetVolley struct {
dice int
src Card // the fainted pet, for the set-aside display
}
// feedAside is Giant Isopod's set-aside: each time the owner plays a pet, spend
// one Trumpet to feed that pet `apples` apples.
type feedAside struct {
apples int
src Card // the fainted pet, for the set-aside display
}
// battleSide is one seat's live state during the simulation.
type battleSide struct {
stack []Card // remaining face-down cards, top first
pending []Card // foods revealed (or prepped) waiting for a pet
unit *BattleUnit
beesFainted int // friendly bees fainted so far (Dog)
petsFainted int // friendly pets fainted so far (Shark)
shields int // Turtle charges: next friendly hit fully prevented
beeBonus int // Turkey aura: +power for later friendly bees
petBonus int // Mammoth aura: +power for later friendly pets
oneShotRocks []setAsideRocks // Badger/Blowfish: on next 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
// --- Golden pack ---
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
grSummoned bool // Golden Retriever already summoned this battle
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
petsPlayed int // pets fielded so far (Komodo's "first pet")
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits
}
// hasPetInStack reports whether any pet remains face-down in the stack.
func (s *battleSide) hasPetInStack() bool {
for _, c := range s.stack {
if c.IsPet() {
return true
}
}
return false
}
// canField reports whether the side has, or can still produce, a pet to fight:
// one in play, a pet left in the stack, or a Golden Retriever waiting to be
// summoned. Used to decide the battle is over only after play effects (and any
// parting shots) have resolved.
func (s *battleSide) canField() bool {
if s.unit != nil || s.hasPetInStack() {
return true
}
return len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned
}
// queuedPlay is a play-time effect waiting to resolve after reveals.
type queuedPlay struct {
seat int
unit *BattleUnit // the unit whose play queued this; nil for set-asides
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,
// limited by Cap. enemy is the opposing side (for enemy-relative multipliers);
// it may be nil when the effect has no such Per.
func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int {
n := e.count()
switch e.Per {
case PerFaintedBees:
n *= s.beesFainted
case PerFaintedPets:
n *= s.petsFainted
case PerEatenApples:
n *= appleCount(u.Foods)
case PerPower:
n *= u.Power()
case PerUniqueFaintedHats:
n *= len(s.faintedHats)
case PerEnemyFaintedPets:
if enemy != nil {
n *= enemy.petsFainted
} else {
n = 0
}
}
if e.Cap > 0 && n > e.Cap {
n = e.Cap
}
return n
}
// resolveBattle simulates the battle from the players' arranged decks,
// records the event log, awards trophies, and moves to PhaseBattle.
//
// The battle is a stack machine: each side reveals cards off the top of
// their deck until a pet is in play (foods along the way attach to it; only
// the last-applied perk counts). If anyone can no longer field a pet the
// battle ends. Otherwise play effects resolve (rocks, strips, steals,
// mills — any of which can faint a pet before the clash). Then the two pets
// deal their full Power to each other simultaneously as damage markers; a
// pet with Damage >= Power faints, firing Faint effects. Survivors that
// took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block
// entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash
// attack manages to hurt. A clash that changes nothing ends the battle as a
// stalemate.
//
// resolveBattle is the orchestrator: it re-runs the (deterministic) simulation
// from the recorded dice/decision tapes, publishing either a completed result
// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark).
func (g *Game) resolveBattle() {
g.NextCardID = g.BattleCardBase
g.battleRollCursor = 0
g.battleDecisionCursor = 0
res, pending := g.runBattle()
g.Battle = res
if pending != nil {
g.PendingBattle = pending
return
}
g.PendingBattle = nil
g.finalizeBattle(res)
}
// finalizeBattle applies the persistent effects of a completed battle: trophies,
// the priority token hand-off, the result log line, and clearing the per-round
// apples-in-play bank. Deferred here (not inside runBattle) because runBattle
// may re-run several times before the battle actually completes.
func (g *Game) finalizeBattle(res *BattleResult) {
n := len(g.Players)
winner := res.WinnerSeat
if winner >= 0 {
g.Players[winner].Trophies += res.Trophies
// Priority token: the winner hands it to the other player; a loser who
// held it keeps it; a draw leaves it put. (Two-player rule.)
if winner == g.PrioritySeat {
g.PrioritySeat = (winner + 1) % n
}
}
if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
} else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
}
for _, p := range g.Players {
p.PendingApplesInPlay = 0
p.PendingTrumpets = 0
}
}
// runBattle plays the simulation to completion or until it needs a mid-battle
// decision, returning the (partial) result and a non-nil pending in the latter
// case. It mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
n := len(g.Players)
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
var suspended *PendingBattleDecision
sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a seat, for log text.
pname := func(seat int) string { return g.Players[seat].Name }
for _, p := range g.Players {
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
sides[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck)
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
}
// enemyOf returns the opposing side (two-player; generalizes later).
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
// seatOrder resolves the priority-token holder first, then everyone else.
// Reveals, queued play effects, and cross-side triggers all follow it, so
// when two pets would act simultaneously (e.g. both throwing rocks) the
// holder acts first — its rocks can faint the enemy pet before that pet's
// own queued rocks resolve.
seatOrder := make([]int, 0, n)
seatOrder = append(seatOrder, g.PrioritySeat)
for seat := range sides {
if seat != g.PrioritySeat {
seatOrder = append(seatOrder, seat)
}
}
// startApple seeds one in-play apple onto a seat's first pet.
startApple := func(seat int) {
apple := g.newApple()
sides[seat].pending = append(sides[seat].pending, apple)
emit(BattleEvent{Type: "prep", Seat: seat, Card: &apple,
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(seat))})
}
// Battle-prep effects that start apples in play (Monkey): they attach
// to the owner's first pet.
for _, p := range g.Players {
for _, c := range p.Deck {
for _, e := range c.Effects {
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
for range e.count() {
startApple(p.Seat)
}
}
}
}
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
// round (read-only here; finalizeBattle clears it once the battle ends,
// so re-runs bank the same amount).
for range p.PendingApplesInPlay {
startApple(p.Seat)
}
// Bird of Paradise: start the battle with Trumpets in the pool.
if p.PendingTrumpets > 0 {
sides[p.Seat].trumpets += p.PendingTrumpets
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
}
}
summon := func(seat int, c Card, cause string) {
s := sides[seat]
s.stack = append([]Card{c}, s.stack...)
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
}
// summonBottom puts a card on the BOTTOM of a seat's stack (Bear).
summonBottom := func(seat int, c Card, cause string) {
s := sides[seat]
s.stack = append(s.stack, c)
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s puts %s %s on the bottom of %s's deck.", cause, article(c.Name), c.Name, pname(seat))})
}
mintFor := func(kind string) Card {
if kind == "bee" {
return g.newBee()
}
return g.newApple()
}
// gainTrumpets adds trumpets to a side and narrates it.
gainTrumpets := func(seat, n int, cause string) {
if n <= 0 {
return
}
sides[seat].trumpets += n
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))})
}
// spend pays an effect's Trumpet cost from a side's pool, narrating the
// spend. Returns false (without paying) when the side can't afford it, so
// the caller skips the effect. A zero-cost effect always "pays".
spend := func(seat int, e Effect, cause string) bool {
if e.CostTrumpet <= 0 {
return true
}
if sides[seat].trumpets < e.CostTrumpet {
return false
}
sides[seat].trumpets -= e.CostTrumpet
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
return true
}
// allowed gates battle-time effects on their conditions.
allowed := func(e Effect, u *BattleUnit) bool {
if g.Round < e.MinRound {
return false
}
switch e.Condition {
case ConditionHasPerk:
return u != nil && u.activePerk() != nil
case ConditionTripled:
return false // shop-time condition; never true in battle
}
return true
}
// hitUnit applies one attack against a seat's pet: shields block the
// whole hit, Garlic shaves per-attack damage. Returns the damage dealt and,
// when a shield absorbed the hit, a shieldBlock describing it (nil
// otherwise). A set-aside Turtle shield is spent before the pet's own
// shields (Melon/Gorilla) so the borrowed card clears the board first.
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) {
u := sides[seat].unit
if u == nil || amount <= 0 {
return 0, nil, nil
}
if sides[seat].shields > 0 {
sides[seat].shields--
var card *Card
source := ""
if n := len(sides[seat].shieldCards); n > 0 {
c := sides[seat].shieldCards[n-1]
sides[seat].shieldCards = sides[seat].shieldCards[:n-1]
card = &c
source = c.Name
}
return 0, &shieldBlock{source: source, release: card}, nil
}
if n := len(u.Shields); n > 0 {
sc := u.Shields[n-1]
u.Shields = u.Shields[:n-1]
return 0, &shieldBlock{source: sc.source, release: sc.card}, nil
}
reduce := u.prevention()
// One partial-prevention charge per hit (whether or not it fully absorbs
// the blow): the pet's own first (Potato), else a side-level set-aside
// (Cone Snail).
if len(u.hitPrevent) > 0 {
amt := u.hitPrevent[0]
u.hitPrevent = u.hitPrevent[1:]
reduce += amt
prevent = &preventInfo{amount: amt}
} else if len(sides[seat].hitPrevent) > 0 {
amt := sides[seat].hitPrevent[0]
sides[seat].hitPrevent = sides[seat].hitPrevent[1:]
reduce += amt
prevent = &preventInfo{amount: amt}
if len(sides[seat].preventCards) > 0 {
c := sides[seat].preventCards[0]
sides[seat].preventCards = sides[seat].preventCards[1:]
prevent.release = &c
}
}
dealt = max(0, amount-reduce)
u.Damage += dealt
return dealt, nil, prevent
}
// emitShield narrates a blocked hit — naming the source rather than a bare
// "shield" — and removes the spent card (Melon food or set-aside Turtle)
// from the play area.
emitShield := func(seat int, petName string, block *shieldBlock) {
txt := fmt.Sprintf("%s blocks the hit.", petName)
if block.source != "" {
txt = fmt.Sprintf("%s blocks the hit with a %s.", petName, block.source)
}
emit(BattleEvent{Type: "shield", Seat: seat, Text: txt})
if block.release != nil {
emit(BattleEvent{Type: "release", Seat: seat, Card: block.release})
}
}
// emitPrevent narrates a Cone Snail partial prevention after the hit that
// consumed it, and drops the spent set-aside card.
emitPrevent := func(seat int, petName string, prev *preventInfo) {
u := sides[seat].unit
after := 0
if u != nil {
after = u.Damage
}
emit(BattleEvent{Type: "prevent", Seat: seat, Count: prev.amount, DamageAfter: after,
Text: fmt.Sprintf("A set-aside Cone Snail shields %s, preventing %d damage.", petName, prev.amount)})
if prev.release != nil {
emit(BattleEvent{Type: "release", Seat: seat, Card: prev.release})
}
}
// faint fires the unit's faint effects (its own and its perk's) in
// effect order, updates faint counters, and notifies enemy pets
// (Hippo's heal).
var faint func(seat int, u *BattleUnit)
faint = func(seat int, u *BattleUnit) {
s := sides[seat]
s.petsFainted++
if isBee(u.Card) {
s.beesFainted++
}
// Track distinct suits among friendly fainted pets (Honduran White
// Bat). Bees and the Golden Retriever have no suit.
if !isBee(u.Card) && u.Card.Suit != "" {
s.faintedHats[u.Card.Suit] = true
}
// setAside marks the fainted pet as kept beside the arena with a
// pending effect, so the client can show its card until it resolves.
setAside := func() {
c := u.Card
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s is set aside.", c.Name)})
}
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
for _, e := range u.effects() {
if e.Trigger != TriggerFaint || !allowed(e, u) {
continue
}
if !spend(seat, e, u.Card.Name) {
continue
}
switch e.Action {
case ActionSummonTop:
target := seat
if e.Target == "enemy" {
target = (seat + 1) % n
}
for range effectCount(e, s, u, enemyOf(seat)) {
summon(target, mintFor(e.Card), cause)
}
case ActionSummonBottom:
for range effectCount(e, s, u, enemyOf(seat)) {
if e.Target == "all" {
for other := range sides {
summonBottom(other, mintFor(e.Card), cause)
}
} else {
summonBottom(seat, mintFor(e.Card), cause)
}
}
case ActionGainTrumpet:
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
case ActionDrainTrumpet:
es := enemyOf(seat)
lost := min(e.count(), es.trumpets)
if lost > 0 {
es.trumpets -= lost
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
}
case ActionPreventNextHit:
s.hitPrevent = append(s.hitPrevent, e.count())
s.preventCards = append(s.preventCards, u.Card)
setAside()
case ActionRecycleApples:
recycled := 0
for _, f := range u.Foods {
if f.Food == FoodApple && recycled < e.count() {
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
recycled++
}
}
case ActionRecyclePerkApples:
// Macaque: recycle up to Count apples, then the active perk on
// top (so the perk reveals first and re-attaches to the next pet).
recycled := 0
for _, f := range u.Foods {
if f.Food == FoodApple && recycled < e.count() {
summon(seat, f, cause)
recycled++
}
}
if perk := u.activePerk(); perk != nil {
summon(seat, *perk, cause)
}
case ActionBeeRocks:
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
setAside()
case ActionFeedOnPlay:
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
setAside()
case ActionGuardRetriever:
s.retrieverGuards = append(s.retrieverGuards, e.count())
setAside()
case ActionDelayedRocks:
s.oneShotRocks = append(s.oneShotRocks,
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
setAside()
case ActionRecurringRocks:
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})
setAside()
case ActionShieldNext:
s.shields += e.count()
s.shieldCards = append(s.shieldCards, u.Card)
setAside()
case ActionBeeAura:
s.beeBonus += e.count()
setAside()
case ActionPetAura:
s.petBonus += e.count()
setAside()
}
}
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
for other, os := range sides {
if other == seat || os.unit == nil || !os.unit.Alive() {
continue
}
for _, e := range os.unit.effects() {
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
continue
}
healed := min(effectCount(e, os, os.unit, sides[seat]), os.unit.Damage)
if healed > 0 {
os.unit.Damage -= healed
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
Text: fmt.Sprintf("%s heals %d after an enemy faints.", os.unit.Card.Name, healed)})
}
}
}
}
// hurt fires Hurt effects on a pet that was damaged and survived.
hurt := func(seat int, u *BattleUnit) {
if !u.Alive() {
return
}
for _, e := range u.effects() {
if e.Trigger != TriggerHurt || !allowed(e, u) {
continue
}
if !spend(seat, e, u.Card.Name) {
continue
}
switch e.Action {
case ActionEatApple:
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
u.Foods = append(u.Foods, g.newApple())
u.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
case ActionSummonTop:
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
}
case ActionGainTrumpet:
gainTrumpets(seat, effectCount(e, sides[seat], u, enemyOf(seat)), fmt.Sprintf("%s's hurt effect", u.Card.Name))
case ActionShieldSelf:
// Gorilla's own hurt-triggered block: innate, no card to drop.
for range e.count() {
u.Shields = append(u.Shields, shieldCharge{})
}
}
}
}
// afterAttack fires After-Attack effects on a pet that survived a clash it
// fought in (Bulldog eats an apple). Effects here are once per battle.
afterAttack := func(seat int, u *BattleUnit) {
if !u.Alive() || u.afterAttackUsed {
return
}
for _, e := range u.effects() {
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
continue
}
if !spend(seat, e, u.Card.Name) {
continue
}
switch e.Action {
case ActionEatApple:
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
u.Foods = append(u.Foods, g.newApple())
u.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
}
u.afterAttackUsed = true
}
}
// 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, source *Card) (killed bool) {
tu := sides[target].unit
if tu == nil || dice <= 0 {
// No target, or a Per-scaled volley that came out to zero (Royal
// Flycatcher / Grizzly with no fainted pets): nothing to animate.
return false
}
roll := 0
faces := make([]int, dice)
for i := range dice {
faces[i] = g.rollRockDie()
roll += faces[i]
}
dealt, block, prev := hitUnit(target, roll)
died := !tu.Alive()
// 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
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,
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
})
if block != nil {
emitShield(target, tu.Card.Name, block)
}
if prev != nil {
emitPrevent(target, tu.Card.Name, prev)
}
if died {
faint(target, tu)
sides[target].unit = nil
return true
}
if dealt > 0 {
hurt(target, tu)
}
return false
}
// nextTarget finds the seat whose pet a standard enemy-directed play
// effect hits.
nextTarget := func(from int) int {
for off := 1; off < n; off++ {
cand := (from + off) % n
if sides[cand].unit != nil {
return cand
}
}
return -1
}
// The exchange loop terminates: every clash kills at least one pet or
// is a detected stalemate, and summons/auras are finite. The guard is
// just insurance as effects get richer.
//
// clashStreak counts how many times the current pair has clashed without
// either fainting. Some effects (e.g. eat-an-apple-when-hurt) can make two
// pets un-killable, looping forever; if they trade blows deadlockLimit
// times in a row, both drop so the battle can proceed. It resets whenever
// the matchup changes (a fresh pet enters).
const deadlockLimit = 9
clashStreak := 0
for range 10_000 {
// Reveal until every side has a pet in play or runs out. Play
// effects queue up and resolve after all reveals (simultaneous).
var plays []queuedPlay
newlyPlayed := make([]bool, n)
for _, seat := range seatOrder {
s := sides[seat]
for s.unit == nil && len(s.stack) > 0 {
c := s.stack[0]
s.stack = s.stack[1:]
if c.IsFood() {
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s's %s is set aside for the next pet.", pname(seat), c.Name)})
s.pending = append(s.pending, c)
continue
}
u := &BattleUnit{Card: c, Foods: s.pending}
u.Bonus += appleCount(s.pending)
u.Bonus += s.petBonus
if isBee(c) {
u.Bonus += s.beeBonus
}
// Pets carry their starting bonus (foods + auras) in the
// reveal event so clients can display it directly.
revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name)
if u.Bonus > 0 {
revealTxt = fmt.Sprintf("%s's %s enters the fray (+%d).", pname(seat), c.Name, u.Bonus)
}
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
s.pending = nil
s.unit = u
s.petsPlayed++
newlyPlayed[seat] = true
// Set-aside payouts fire before the new pet's own play
// effects.
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, source: &src})
}
s.oneShotRocks = nil
for _, r := range s.recurringRocks {
src := r.src
plays = append(plays, queuedPlay{seat: seat,
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
}
// Poison Dart Frog: rocks whenever a Bee is played.
if isBee(c) {
for _, r := range s.beePlayRocks {
src := r.src
plays = append(plays, queuedPlay{seat: seat,
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
}
}
// Giant Isopod: each set-aside spends one Trumpet (mandatory when
// affordable) to feed the just-played pet its apples.
for i := range s.feedOnPlay {
if s.trumpets <= 0 {
break
}
s.trumpets--
src := s.feedOnPlay[i].src
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -1,
Text: fmt.Sprintf("%s spends 1 Trumpet.", src.Name)})
for range s.feedOnPlay[i].apples {
u.Foods = append(u.Foods, g.newApple())
u.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)})
}
// 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
// Melon perk that should be shown and later dropped).
addPlay := func(e Effect, perk *Card) {
if e.Trigger != TriggerPlay {
return
}
// Shields apply the instant the pet enters play, ahead of
// any queued rocks (Melon; Wildebeest, which pays Trumpets).
if e.Action == ActionShieldSelf {
if !spend(seat, e, u.Card.Name) {
return
}
for range e.count() {
ch := shieldCharge{}
if perk != nil {
food := *perk
ch = shieldCharge{source: perk.Name, card: &food}
}
u.Shields = append(u.Shields, ch)
}
return
}
// Potato's partial preventions likewise arm on entry.
if e.Action == ActionPreventSelf {
for range max(e.Cap, 1) {
u.hitPrevent = append(u.hitPrevent, e.count())
}
return
}
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
}
for _, e := range u.Card.Effects {
addPlay(e, nil)
}
if perk := u.activePerk(); perk != nil {
for _, e := range perk.Effects {
addPlay(e, perk)
}
}
}
// Golden pack: a side that has run out of cards but still holds
// Trumpets fields a one-time Golden Retriever, its Power equal to
// those Trumpets. This happens before the "anyone out?" check so it
// can still clash.
if s.unit == nil && len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned {
gr := g.newGoldenRetriever(s.trumpets)
grUnit := &BattleUnit{Card: gr}
// German Shepherd: each set-aside guards the Golden Retriever's
// first hits (partial preventions).
grUnit.hitPrevent = append(grUnit.hitPrevent, s.retrieverGuards...)
s.unit = grUnit
s.grSummoned = true
s.petsPlayed++
newlyPlayed[seat] = true
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &gr, Count: s.trumpets, Bonus: 0,
Text: fmt.Sprintf("%s is out of cards — a Golden Retriever charges in with %d Trumpet%s (Power %d).",
pname(seat), s.trumpets, plural(s.trumpets), s.trumpets)})
}
}
// Cross-side play triggers: Rhino rocks anyone who just played;
// Crocodile volleys when the enemy plays their last pet.
for _, seat := range seatOrder {
if !newlyPlayed[seat] {
continue
}
for _, other := range seatOrder {
os := sides[other]
if other == seat {
continue
}
if os.unit != nil {
for _, e := range os.unit.effects() {
if e.Trigger == TriggerEnemyPlay && e.Action == ActionThrowRock && allowed(e, os.unit) {
plays = append(plays, queuedPlay{seat: other, unit: os.unit, effect: e})
}
}
}
if !sides[seat].hasPetInStack() {
for _, v := range os.lastPetRocks {
src := v.src
plays = append(plays, queuedPlay{seat: other,
effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src, source: &src})
}
os.lastPetRocks = nil
}
}
}
// A fresh pet on either side is a new matchup, so the deadlock streak
// starts over.
for _, np := range newlyPlayed {
if np {
clashStreak = 0
break
}
}
// Resolve play effects. Any of these can faint a pet before the clash.
// This runs before the "battle over" check so a parting shot fires even
// 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 {
continue
}
if q.unit != nil && !allowed(q.effect, q.unit) {
continue
}
costName := pname(q.seat)
if q.unit != nil {
costName = q.unit.Card.Name
}
if !spend(q.seat, q.effect, costName) {
continue
}
switch q.effect.Action {
case ActionThrowRock:
dice := q.effect.count()
if q.unit != nil {
dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
}
switch {
case q.everyone:
for seat := range sides {
if throwRocks(q.seat, seat, dice, q.source) {
anyDeath = true
}
}
case q.effect.Target == "self":
// Manatee pelts its own pet.
if throwRocks(q.seat, q.seat, dice, q.source) {
anyDeath = true
}
default:
if t := nextTarget(q.seat); t >= 0 {
if throwRocks(q.seat, t, dice, q.source) {
anyDeath = true
}
}
}
if q.release != nil {
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
}
case ActionGainTrumpet:
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
case ActionDoubleTrumpets:
gain := sides[q.seat].trumpets
if q.effect.Cap > 0 && gain > q.effect.Cap {
gain = q.effect.Cap
}
gainTrumpets(q.seat, gain, costName)
case ActionStealPerk:
t := nextTarget(q.seat)
if t < 0 {
continue
}
tu := sides[t].unit
perk := tu.activePerk()
if perk == nil {
continue
}
stolen := *perk
// Drop that perk food from the enemy pet (perks add no power, so
// no bonus change) and put it on top of the thief's deck.
for i := range tu.Foods {
if tu.Foods[i].ID == stolen.ID {
tu.Foods = append(tu.Foods[:i], tu.Foods[i+1:]...)
break
}
}
summon(q.seat, stolen, fmt.Sprintf("%s's ability", q.unit.Card.Name))
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t,
Text: fmt.Sprintf("%s snatches %s's %s.", q.unit.Card.Name, tu.Card.Name, stolen.Name)})
case ActionStripApples:
t := nextTarget(q.seat)
if t < 0 {
continue
}
tu := sides[t].unit
apples := appleCount(tu.Foods)
if apples == 0 {
continue
}
kept := tu.Foods[:0]
for _, f := range tu.Foods {
if f.Food != FoodApple {
kept = append(kept, f)
}
}
tu.Foods = kept
tu.Bonus -= apples
died := !tu.Alive()
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
Text: fmt.Sprintf("%s discards %s's apples.", q.unit.Card.Name, tu.Card.Name)})
if died {
faint(t, tu)
sides[t].unit = nil
anyDeath = true
}
case ActionStripFoods:
t := nextTarget(q.seat)
if t < 0 {
continue
}
tu := sides[t].unit
tu.Bonus -= appleCount(tu.Foods)
tu.Foods = nil
died := !tu.Alive()
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
Text: fmt.Sprintf("%s strips %s's apples away.", q.unit.Card.Name, tu.Card.Name)})
if died {
faint(t, tu)
sides[t].unit = nil
anyDeath = true
}
case ActionStealApples:
t := nextTarget(q.seat)
if t < 0 {
continue
}
tu := sides[t].unit
steal := min(q.effect.count(), appleCount(tu.Foods))
if steal == 0 {
continue
}
moved := 0
kept := tu.Foods[:0]
for _, f := range tu.Foods {
if f.Food == FoodApple && moved < steal {
q.unit.Foods = append(q.unit.Foods, f)
moved++
continue
}
kept = append(kept, f)
}
tu.Foods = kept
tu.Bonus -= moved
q.unit.Bonus += moved
died := !tu.Alive()
emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died,
Text: fmt.Sprintf("%s steals %d apple%s from %s.", q.unit.Card.Name, moved, plural(moved), tu.Card.Name)})
if died {
faint(t, tu)
sides[t].unit = nil
anyDeath = true
}
case ActionMillEnemy:
t := (q.seat + 1) % n
ts := sides[t]
for len(ts.stack) > 0 {
top := ts.stack[0]
if top.IsPet() && !isBee(top) {
break
}
ts.stack = ts.stack[1:]
emit(BattleEvent{Type: "mill", Seat: t, Card: &top,
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
}
case ActionSummonTop:
for range effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) {
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
}
case ActionEatApple:
count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
if count > 0 {
for range count {
q.unit.Foods = append(q.unit.Foods, g.newApple())
q.unit.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
}
case ActionShuffleApples:
// Komodo: only if it's the side's first pet, shuffle apples into
// random deck positions (deterministic via the battle draw tape).
if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 {
continue
}
s := sides[q.seat]
for range q.effect.count() {
pos := g.battleDraw(len(s.stack) + 1)
apple := g.newApple()
s.stack = slices.Insert(s.stack, pos, apple)
emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple,
Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))})
}
case ActionSpendRocks:
// Nurse Shark: the owner chooses how many Trumpets (0..available,
// capped at Count) to spend; each throws two rocks. This is the one
// mid-battle decision — it may suspend the whole simulation.
s := sides[q.seat]
maxSpend := min(q.effect.count(), s.trumpets)
choice, pending := g.decideBattle(PendingBattleDecision{
Seat: q.seat, Kind: "nurseShark", PetName: q.unit.Card.Name,
Min: 0, Max: maxSpend, Trumpets: s.trumpets,
})
if pending != nil {
suspended = pending
break
}
if choice > 0 {
s.trumpets -= choice
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
if t := nextTarget(q.seat); t >= 0 {
if throwRocks(q.seat, t, 2*choice, nil) {
anyDeath = true
}
}
}
}
if suspended != nil {
break // stop mid-plays; the battle re-runs once the choice is in
}
}
// A pending decision unwinds the whole simulation; the events emitted so
// far are a valid prefix the re-run reproduces exactly.
if suspended != nil {
return res, suspended
}
// Battle over? A side that can no longer field a pet is out (checked
// after play effects so parting shots land, using canField so a pet that
// will simply refill next reveal doesn't count as out).
anyOut := false
for _, s := range sides {
if !s.canField() {
anyOut = true
}
}
if anyOut {
break
}
if anyDeath {
continue // refill before any clash
}
// Clash. Two-player for now; >2-player battle pairings come later
// (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit
powA, powB := ua.Power(), ub.Power()
dealtA, blockA, prevA := hitUnit(0, powB)
dealtB, blockB, prevB := hitUnit(1, powA)
// Scorpion: a clash attack that hurts, KOs.
if dealtA > 0 && ub.hasKnockout() {
ua.Damage = max(ua.Damage, ua.Power())
}
if dealtB > 0 && ua.hasKnockout() {
ub.Damage = max(ub.Damage, ub.Power())
}
// Deadlock guard: if the pair keeps surviving, count the exchanges and
// force a mutual knockout once they hit the limit.
deadlocked := false
if ua.Alive() && ub.Alive() {
clashStreak++
if clashStreak >= deadlockLimit {
deadlocked = true
ua.Damage = max(ua.Damage, ua.Power())
ub.Damage = max(ub.Damage, ub.Power())
}
} else {
clashStreak = 0
}
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
switch da, db := !ua.Alive(), !ub.Alive(); {
case deadlocked:
clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit)
case da && db:
clashTxt += " Both faint."
case da:
clashTxt += fmt.Sprintf(" %s faints.", ua.Card.Name)
case db:
clashTxt += fmt.Sprintf(" %s faints.", ub.Card.Name)
}
emit(BattleEvent{
Type: "clash",
Damage: []int{ua.Damage, ub.Damage},
Died: []bool{!ua.Alive(), !ub.Alive()},
Text: clashTxt,
})
if blockA != nil {
emitShield(0, ua.Card.Name, blockA)
}
if blockB != nil {
emitShield(1, ub.Card.Name, blockB)
}
if prevA != nil {
emitPrevent(0, ua.Card.Name, prevA)
}
if prevB != nil {
emitPrevent(1, ub.Card.Name, prevB)
}
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
blockA == nil && blockB == nil && prevA == nil && prevB == nil {
break // stalemate: nothing can ever change
}
dealt := []int{dealtA, dealtB}
for seat, u := range []*BattleUnit{ua, ub} {
if !u.Alive() {
faint(seat, u)
sides[seat].unit = nil
} else {
if dealt[seat] > 0 {
hurt(seat, u)
}
afterAttack(seat, u)
}
}
}
// A single side still holding a pet in play wins; anything else
// (everyone out, or a stalemate with pets on both sides) is a draw.
winner := -1
for seat, s := range sides {
if s.unit != nil {
if winner >= 0 {
winner = -1 // stalemate / >2-player safety
break
}
winner = seat
}
}
res.WinnerSeat = winner
if winner >= 0 {
res.Trophies = 1
if g.Round == MaxRounds {
res.Trophies = 2
}
}
return res, nil
}