package game import ( "fmt" ) // 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 // Survivors is each seat's remaining force at battle end: pets still in // play plus any never reached in the stack. The loser is 0. It measures how // decisive the result was — the margin the AI uses to prefer a lineup that // fights harder, even in a battle it can't win. Survivors []int `json:"survivors,omitempty"` } // 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 runs the (deterministic) simulation and // publishes the completed result. func (g *Game) resolveBattle() { res := g.runBattle() g.Battle = res 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. Kept separate from runBattle, which mutates no persistent // player state. 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, returning the result. It // mutates no persistent player state — that is finalizeBattle's job. func (g *Game) runBattle() *BattleResult { n := len(g.Players) res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} 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 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) { 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 } 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 { // 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 } 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 — 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) { 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, add apples to the // remaining deck and shuffle the whole thing so the apples and the // existing pets intermix (not just apples dropped in). if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 { continue } s := sides[q.seat] for range q.effect.count() { apple := g.newApple() s.stack = append(s.stack, 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))}) } g.battleShuffle(s.stack) case ActionSpendRocks: // Nurse Shark: spend as many Trumpets as available (up to Count) to // throw two rocks each. s := sides[q.seat] choice := min(q.effect.count(), s.trumpets) 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 } } } } } // 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() { // 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 { if dealt[seat] > 0 { hurt(seat, u) } afterAttack(seat, u) } } } // A single side that can still field a pet wins; anything else (everyone // out, or a stalemate with pets on both sides) is a draw. We test canField, // not unit, because the loop can break the instant one side runs out while // the other's current pet has just fainted — that side still has pets left // in its stack (it simply wasn't refilled) and is the rightful winner. winner := -1 for seat, s := range sides { if s.canField() { 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 } } // Record each side's leftover force: a live pet in play plus any pets never // reached in the stack. The loser lands on 0. res.Survivors = make([]int, n) for seat, s := range sides { cnt := 0 if s.unit != nil && s.unit.Alive() { cnt++ } for _, c := range s.stack { if c.IsPet() { cnt++ } } res.Survivors[seat] = cnt } return res }