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 // Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the // damage it deals in a clash (min 0); Exposed raises the damage it takes on // each hit. bakuGuard, when set, discards the first Ailment it would gain. Spooked int `json:"spooked,omitempty"` Exposed int `json:"exposed,omitempty"` bakuGuard bool } // 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() } // hasAilment reports whether the pet carries any Ailment (Mothman's condition). func (u *BattleUnit) hasAilment() bool { return u.Spooked > 0 || u.Exposed > 0 } // takeAilment attaches one Ailment of the given kind, unless Baku's guard eats // it first. It reports whether the ailment actually landed. func (u *BattleUnit) takeAilment(kind string) bool { if u.bakuGuard { u.bakuGuard = false return false } switch kind { case AilmentSpooked: u.Spooked++ case AilmentExposed: u.Exposed++ } return true } // 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). // "mana": Seat's Mana pool changed by Count (+gain / −spend) (Unicorn). // "ailment": Seat's pet gained Count Ailment cards (Card names the kind) // (Unicorn: Barghest, Basilisk, Frost Wolf, …). // "bounce": Seat's pet sent Target's pet (Card) to the bottom of Target's // deck (Unicorn: Loveland Frogman). 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 battle. // // A round runs one battle per pairing (see schedule.go), so a six-player round // produces three of these. Everything inside a result is indexed by *side* — // 0 or 1 within this battle — not by the player's seat at the table: Seats maps // the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat // is the exception, and is a real seat, because it's the one field that means // something outside the battle. type BattleResult struct { Round int `json:"round"` // Seats are the two players fighting, in first-player order: Seats[0] holds // priority and acts first when two effects would land simultaneously. Seats []int `json:"seats"` StackSizes []int `json:"stackSizes"` // starting deck size per side // Lineups is each side'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"` // a seat at the table; -1 = draw Trophies int `json:"trophies"` // awarded to the winner // Survivors is each side'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"` // ManaAfter (Unicorn pack) is each side's persistent Mana pool once the // battle ends; finalizeBattle writes it back to the players. NextRoundApples // is apples each side banked for next round's hand (Skeleton Dog). ManaAfter []int `json:"manaAfter,omitempty"` NextRoundApples []int `json:"nextRoundApples,omitempty"` // Draws is every random draw this battle made, in order: rock die faces, // Komodo's apple shuffle, random target picks. It's a recording, not an // input — feed it back through ReplayResult and the identical battle plays // out, which is how a debug report reproduces a fight that went wrong. // Public like the rest of the result: the dice are rolled in the open. Draws []int `json:"draws,omitempty"` // Inputs is the rest of what the battle started from, per side, and // StartCardID is the card-id counter it began minting apples at. Recorded // because the round clears those banks the moment the battles end, so // without them a result can't be replayed after the fact. Nothing here is // private: the battle already announces each of them in its own events. Inputs []BattleInputs `json:"inputs,omitempty"` StartCardID int `json:"startCardId,omitempty"` } // Replayable reports whether this result carries the recording a faithful // replay needs. Results saved before the engine recorded battles don't: their // lineups are still on file, so the fight can be re-run, but the dice will fall // where they may and the outcome may differ from what the player saw. func (r *BattleResult) Replayable() bool { return r.StartCardID > 0 } // BattleInputs is the persistent player state one side brought into a battle — // everything runBattle reads off the Player besides the arranged lineup. type BattleInputs struct { Mana int `json:"mana,omitempty"` // Player.Mana (Unicorn) Trumpets int `json:"trumpets,omitempty"` // Player.PendingTrumpets (Golden) ApplesInPlay int `json:"applesInPlay,omitempty"` // Player.PendingApplesInPlay (Golden) } // Side returns the battle-side index (0 or 1) for a seat at the table, or -1 // if that player wasn't in this battle. Use it to read any of the per-side // slices above from a seat. func (r *BattleResult) Side(seat int) int { return slices.Index(r.Seats, seat) } // Has reports whether a seat fought in this battle. func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 } // SeatOf returns the seat holding a side of this battle, or -1. func (r *BattleResult) SeatOf(side int) int { if side < 0 || side >= len(r.Seats) { return -1 } return r.Seats[side] } // 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 // --- Unicorn pack --- mana int // persistent Mana pool, seeded from the player and written back pendingAilments []Card // ailments on top of the deck, waiting for the next pet negators []Card // Mandrake set-asides: each negates the next enemy Faint ability nextRoundApples int // apples banked for next round's hand (Skeleton Dog) // --- Unicorn pack, tiers 4-6 --- unicornGuards []Card // Unicorn: each turns the next incoming friendly ailment into 2 apples smallPetBonus int // Rootlin: +power for friendly pets with base Power <= 2 fairyGuards []Card // Fairy: each recycles the next friendly faint to the deck bottom ailmentBoost int // Manticore: enemy pets' Ailments count for this much more manaFeed []feedAside // Team Spirit: each pet played, spend 1 Mana to feed apples } // 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 } // resolveBattles fights every pairing of the current round, records the event // logs, and awards trophies. // // Each 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. // // resolveBattles is the orchestrator: it runs each (deterministic) simulation // and publishes the completed results. func (g *Game) resolveBattles() { g.Battles = nil for _, m := range g.Pairings() { first, second := g.firstPlayer(m) res := g.runBattle(first, second) g.Battles = append(g.Battles, res) g.finalizeBattle(res) } // Per-round bookkeeping that isn't tied to one battle: the temporary // resources every player banked for the fight are spent now, win or lose. for _, p := range g.Players { p.PendingApplesInPlay = 0 p.PendingTrumpets = 0 } } // firstPlayer decides which half of a pairing acts first — the side that wins // simultaneity races during the battle. Two players settle it with the // priority token they pass between them; a bigger table flips for it, as the // rulebook's "determine the First Player for each battle by flipping a gold // token" asks. func (g *Game) firstPlayer(m Matchup) (first, second int) { if len(g.Players) == 2 { if m[1] == g.PrioritySeat { return m[1], m[0] } return m[0], m[1] } if randInt(2) == 1 { return m[1], m[0] } return m[0], m[1] } // finalizeBattle applies the persistent effects of one completed battle: // trophies, the round-win record, the priority token hand-off, and the result // log line. Kept separate from runBattle, which mutates no persistent player // state. func (g *Game) finalizeBattle(res *BattleResult) { winner := res.WinnerSeat if winner >= 0 { g.Players[winner].Trophies += res.Trophies g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round) // Priority token (two-player rule): the winner hands it to the other // player; a loser who held it keeps it; a draw leaves it put. At bigger // tables the token instead walks the table each round (startShopRound). if len(g.Players) == 2 && winner == g.PrioritySeat { g.PrioritySeat = (winner + 1) % len(g.Players) } } // The result line names the table it came from, since several resolve at once. loser := res.SeatOf(0) if loser == winner { loser = res.SeatOf(1) } if winner < 0 { g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult, Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))}) } else { g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult, Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)}) } for _, seat := range res.Seats { p := g.Players[seat] side := res.Side(seat) // Unicorn pack: persist the Mana pool as it stood at battle's end, and // bank any apples destined for next round's hand (Skeleton Dog). if side < len(res.ManaAfter) { p.Mana = res.ManaAfter[side] } if side < len(res.NextRoundApples) { p.NextRoundApples += res.NextRoundApples[side] } } } // runBattle plays one pairing's simulation to completion, returning the // result. It mutates no persistent player state — that is finalizeBattle's job. // // first and second are the seats fighting, first having priority. Everything // below works in *side* indices — 0 is first, 1 is second — so the resolver // only ever deals with two combatants no matter how big the table is; res.Seats // maps back out. Read `seat` in this function as "side" throughout. func (g *Game) runBattle(first, second int) *BattleResult { const n = 2 // sides in a battle, not players at the table seats := []int{first, second} res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats, StackSizes: make([]int, n), Lineups: make([][]Card, n)} // Every die this battle rolls lands on the tape and ships with the result, // so a debug report can replay the fight exactly (see debug.go). The tape // belongs to one battle: start it empty and hand it over on the way out. g.drawTape = nil defer func() { res.Draws, g.drawTape, g.drawReplay = g.drawTape, nil, nil }() res.StartCardID = g.NextCardID res.Inputs = make([]BattleInputs, 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 side, for log text. pname := func(side int) string { return g.Players[seats[side]].Name } for side, seat := range seats { p := g.Players[seat] s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} // Unicorn pack: the persistent Mana pool comes into battle (read-only // here; written back by finalizeBattle so re-runs stay deterministic). s.mana = p.Mana sides[side] = s res.StackSizes[side] = len(p.Deck) res.Lineups[side] = append([]Card(nil), p.Deck...) // Everything else runBattle reads off the Player, banked for the replay. res.Inputs[side] = BattleInputs{ Mana: p.Mana, Trumpets: p.PendingTrumpets, ApplesInPlay: p.PendingApplesInPlay, } } // enemyOf returns the opposing side. enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] } // seatOrder resolves the first player before the second. Reveals, queued // play effects, and cross-side triggers all follow it, so when two pets // would act simultaneously (e.g. both throwing rocks) the first player acts // first — its rocks can faint the enemy pet before that pet's own queued // rocks resolve. Side 0 is the first player by construction. seatOrder := []int{0, 1} // 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 side, seat := range seats { p := g.Players[seat] for _, c := range p.Deck { for _, e := range c.Effects { if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay { for range e.count() { startApple(side) } } } } // Golden pack: apples-in-play banked by a sold Hercules Beetle this // round (read-only here; resolveBattles clears it once the round's // battles end, so re-runs bank the same amount). for range p.PendingApplesInPlay { startApple(side) } // Bird of Paradise: start the battle with Trumpets in the pool. if p.PendingTrumpets > 0 { sides[side].trumpets += p.PendingTrumpets emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets, Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), 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))}) } // gainMana adds Mana to a side's persistent pool and narrates it. gainMana := func(seat, n int, cause string) { if n <= 0 { return } sides[seat].mana += n emit(BattleEvent{Type: "mana", Seat: seat, Count: n, Text: fmt.Sprintf("%s gains %d Mana (now %d).", cause, n, sides[seat].mana)}) } // spend pays an effect's Trumpet and Mana costs from a side's pools, // narrating each. Returns false (paying nothing) when the side can't afford // the full cost, so the caller skips the effect. A cost-free effect always // "pays". spend := func(seat int, e Effect, cause string) bool { s := sides[seat] if s.trumpets < e.CostTrumpet || s.mana < e.CostMana { return false } if e.CostTrumpet > 0 { s.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))}) } if e.CostMana > 0 { s.mana -= e.CostMana emit(BattleEvent{Type: "mana", Seat: seat, Count: -e.CostMana, Text: fmt.Sprintf("%s spends %d Mana.", cause, e.CostMana)}) } return true } // unicornConvert consumes one of side `s`'s Unicorn guards, if any, turning // an incoming ailment into 2 Apples on top of that side's deck. Returns true // when a guard fired (so the caller skips applying the ailment). unicornConvert := func(seat int) bool { s := sides[seat] if len(s.unicornGuards) == 0 { return false } card := s.unicornGuards[len(s.unicornGuards)-1] s.unicornGuards = s.unicornGuards[:len(s.unicornGuards)-1] emit(BattleEvent{Type: "release", Seat: seat, Card: &card, Text: fmt.Sprintf("%s's Unicorn turns an Ailment into 2 Apples.", pname(seat))}) for range 2 { apple := g.newApple() s.stack = append([]Card{apple}, s.stack...) emit(BattleEvent{Type: "summon", Seat: seat, Card: &apple, Text: fmt.Sprintf("%s adds an apple on top of %s's deck.", pname(seat), pname(seat))}) } return true } // addAilment (Unicorn pack) afflicts the enemy of `from`. toDeck drops the // ailment cards on top of the enemy deck (they snare the next pet revealed); // otherwise they attach to the enemy pet in play now. A friendly Unicorn // guard converts each incoming ailment to 2 apples; Baku eats one on a pet. addAilment := func(from int, kind string, count int, toDeck bool, cause string) { if count <= 0 { return } target := (from + 1) % n ts := sides[target] if toDeck { for range count { if unicornConvert(target) { continue } a := g.newAilment(kind) ts.stack = append([]Card{a}, ts.stack...) emit(BattleEvent{Type: "summon", Seat: target, Card: &a, Text: fmt.Sprintf("%s adds %s on top of %s's deck.", cause, a.Name, pname(target))}) } return } if ts.unit == nil { return } rep := g.newAilment(kind) landed, guarded := 0, 0 for range count { if unicornConvert(target) { continue } if ts.unit.takeAilment(kind) { landed++ } else { guarded++ } } if landed == 0 && guarded == 0 { return // every ailment was converted to apples; nothing to narrate here } txt := fmt.Sprintf("%s afflicts %s with %d %s.", cause, ts.unit.Card.Name, landed, rep.Name) if landed == 0 { txt = fmt.Sprintf("%s shrugs off the %s.", ts.unit.Card.Name, rep.Name) } else if guarded > 0 { txt = fmt.Sprintf("%s afflicts %s with %d %s (one shrugged off).", cause, ts.unit.Card.Name, landed, rep.Name) } emit(BattleEvent{Type: "ailment", Seat: target, Card: &rep, Count: landed, Text: txt}) } // 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 case ConditionEvenRound: return g.Round%2 == 0 } return true } // effAilment returns a seat's pet's effective Spooked/Exposed values, // including any boost from an enemy Manticore set aside on the other side. // A pet with none of that ailment gets no boost. (Unicorn pack.) effAilment := func(seat, base int) int { if base == 0 { return 0 } return base + sides[(seat+1)%n].ailmentBoost } // 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 } // Unicorn pack: Exposed raises the incoming damage of a landed hit. A // shield still blocks the whole (raised) hit; Garlic/preventions shave it. amount += effAilment(seat, u.Exposed) 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++ } // Unicorn pack: a Fairy set aside BEFORE this faint recycles the fallen // pet to the bottom of the deck. Snapshot the count now so a Fairy arming // on THIS faint doesn't recycle itself. fairyBefore := len(s.fairyGuards) // 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)}) } // Unicorn pack: a set-aside Mandrake on the enemy side cancels this // pet's Faint ability (the pet still faints; its effects just don't run). if es := enemyOf(seat); len(es.negators) > 0 { hasFaint := false for _, e := range u.effects() { if e.Trigger == TriggerFaint { hasFaint = true break } } if hasFaint { m := es.negators[len(es.negators)-1] es.negators = es.negators[:len(es.negators)-1] emit(BattleEvent{Type: "release", Seat: (seat + 1) % n, Card: &m, Text: fmt.Sprintf("%s's Mandrake cancels %s's faint ability.", pname((seat+1)%n), u.Card.Name)}) goto enemyReactions } } { 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() case ActionGainMana: gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause) case ActionAddAilment: addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause) case ActionNextRoundApple: // Banked for next round's hand; surfaced then in the shop log // rather than as a battle-board change now. s.nextRoundApples += e.count() case ActionNegateEnemyFaint: s.negators = append(s.negators, u.Card) setAside() case ActionReviveSelf: // Slime: put a plain copy back on top of the deck — no faint // ability, so it can't loop. "Once per round" falls out of that. revived := u.Card revived.ID = g.newCardID() revived.Effects = nil revived.EffectText = "" summon(seat, revived, cause) case ActionAilmentToApples: s.unicornGuards = append(s.unicornGuards, u.Card) setAside() case ActionSmallPetAura: s.smallPetBonus += e.count() setAside() case ActionAilmentBoost: s.ailmentBoost += e.count() setAside() case ActionManaFeedOnPlay: s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card}) setAside() case ActionReviveNextFaint: s.fairyGuards = append(s.fairyGuards, u.Card) setAside() case ActionSummonFromDiscard: // Chimera: add Count random cards from the FromTier discard pile as // temporary copies on top of the deck. pile := g.Discards[e.FromTier] for range effectCount(e, s, u, enemyOf(seat)) { if len(pile) == 0 { break } pick := pile[g.battleDraw(len(pile))] copyC := pick copyC.ID = g.newCardID() copyC.Temporary = true summon(seat, copyC, cause) } case ActionSummonFromTierDeck: // Pixiu: a temporary copy of the top of the FromTier shop deck. if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) { deck := g.ShopDecks[e.FromTier-1] if len(deck) > 0 { copyC := deck[0] copyC.ID = g.newCardID() copyC.Temporary = true summon(seat, copyC, cause) } } } } } enemyReactions: // Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck // bottom (a fresh copy, so it re-enters later and can faint again). if fairyBefore > 0 { s.fairyGuards = s.fairyGuards[:len(s.fairyGuards)-1] revived := u.Card revived.ID = g.newCardID() s.stack = append(s.stack, revived) emit(BattleEvent{Type: "summon", Seat: seat, Card: &revived, Text: fmt.Sprintf("A set-aside Fairy sends %s to the bottom of %s's deck.", u.Card.Name, pname(seat))}) } // 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{}) } case ActionHeal: // Unicorn pack: Health Potion perk removes damage markers. healed := min(e.count(), u.Damage) if healed > 0 { u.Damage -= healed emit(BattleEvent{Type: "heal", Seat: seat, DamageAfter: u.Damage, Text: fmt.Sprintf("%s heals %d after being hurt.", u.Card.Name, healed)}) } } } } // afterAttack fires After-Attack effects on a pet that survived a clash it // fought in (Bulldog eats an apple once per battle; Behemoth every clash). afterAttack := func(seat int, u *BattleUnit) { if !u.Alive() { return } for _, e := range u.effects() { if e.Trigger != TriggerAfterAttack || !allowed(e, u) { continue } // Bulldog's charge is once per battle; Behemoth's fires every clash. if e.Once && u.afterAttackUsed { 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)}) } if e.Once { 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 and how // many dice came up blank (a 0), for Tatzelwurm's mana payout. throwRocks := func(from, target, dice int, source *Card) (killed bool, blanks int, dealt int) { 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, 0, 0 } roll := 0 faces := make([]int, dice) for i := range dice { faces[i] = g.rollRockDie() roll += faces[i] if faces[i] == 0 { blanks++ } } 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, blanks, dealt } if dealt > 0 { hurt(target, tu) } return false, blanks, dealt } // 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 } if c.IsAilment() { // Unicorn pack: an ailment on top of the deck (Frost Wolf, Slime) // waits to attach to the next pet revealed on this side. emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Text: fmt.Sprintf("%s's %s waits for the next pet.", pname(seat), c.Name)}) s.pendingAilments = append(s.pendingAilments, c) continue } // Unicorn pack: Sleipnir's base Power becomes the owner's Mana // (capped) at the moment it is played. for _, e := range c.Effects { if e.Trigger == TriggerPassive && e.Action == ActionManaPower { c.Power = s.mana if e.Cap > 0 && c.Power > e.Cap { c.Power = e.Cap } } } u := &BattleUnit{Card: c, Foods: s.pending} u.Bonus += appleCount(s.pending) u.Bonus += s.petBonus if isBee(c) { u.Bonus += s.beeBonus } // Unicorn pack: Rootlin buffs small pets (base Power <= 2). if c.Power <= 2 { u.Bonus += s.smallPetBonus } // Unicorn pack: Baku discards the first ailment it would take. for _, e := range c.Effects { if e.Trigger == TriggerPassive && e.Action == ActionAilmentImmune { u.bakuGuard = true } } // Ailments waiting on this deck attach now, respecting Baku's // guard; each landing is narrated so the client updates its badges. for _, a := range s.pendingAilments { landed := 0 if u.takeAilment(a.Ailment) { landed = 1 } ac := a emit(BattleEvent{Type: "ailment", Seat: seat, Card: &ac, Count: landed, Text: fmt.Sprintf("%s's %s latches onto %s.", pname(seat), a.Name, c.Name)}) } s.pendingAilments = nil // 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)}) } // Unicorn pack: Team Spirit spends 1 Mana per set-aside to feed the // just-played pet its apples (mandatory when affordable). for i := range s.manaFeed { if s.mana <= 0 { break } s.mana-- src := s.manaFeed[i].src emit(BattleEvent{Type: "mana", Seat: seat, Count: -1, Text: fmt.Sprintf("%s spends 1 Mana.", src.Name)}) for range s.manaFeed[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.manaFeed[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 // Index-based so effects queued mid-loop (Abomination mimicking a Play // ability) are resolved in the same pass. for i := 0; i < len(plays); i++ { q := plays[i] // 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 killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed { anyDeath = true } } case q.effect.Target == "self": // Manatee pelts its own pet. if killed, _, _ := throwRocks(q.seat, q.seat, dice, q.source); killed { anyDeath = true } default: if t := nextTarget(q.seat); t >= 0 { if killed, _, _ := throwRocks(q.seat, t, dice, q.source); killed { anyDeath = true } } } if q.release != nil { emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release}) } case ActionThrowRockGainMana: // Tatzelwurm: throw the rocks, then gain 1 Mana per blank rolled. dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) if t := nextTarget(q.seat); t >= 0 { killed, blanks, _ := throwRocks(q.seat, t, dice, q.source) if killed { anyDeath = true } gainMana(q.seat, blanks, costName) } case ActionGainMana: gainMana(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName) case ActionAddAilment: addAilment(q.seat, q.effect.Ailment, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), q.effect.Target == "enemyDeck", costName) // Calygreyhound also eats apples alongside the spook. if q.effect.Eat > 0 && q.unit != nil { for range q.effect.Eat { 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 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: // Unicorn pack: Mothman only eats if the enemy pet is ailing. if q.effect.Condition == ConditionEnemyAilment { et := nextTarget(q.seat) if et < 0 || sides[et].unit == nil || !sides[et].unit.hasAilment() { continue } } 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 killed, _, _ := throwRocks(q.seat, t, 2*choice, nil); killed { anyDeath = true } } } case ActionRockThenEat: // Vampire Bat: (if the enemy is ailing) throw rocks, then eat apples // equal to the damage those rocks actually dealt (Exposed counts). t := nextTarget(q.seat) if t < 0 { continue } if q.effect.Condition == ConditionEnemyAilment && !sides[t].unit.hasAilment() { continue } dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) killed, _, dealt := throwRocks(q.seat, t, dice, nil) if killed { anyDeath = true } if dealt > 0 && sides[q.seat].unit == q.unit { for range dealt { 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 %d apple%s from the damage dealt (now +%d).", q.unit.Card.Name, dealt, plural(dealt), q.unit.Bonus)}) } case ActionBounceEnemy: // Loveland Frogman: send the enemy pet to the bottom of the enemy // deck as a fresh body with its Play ability stripped (so two of them // can't bounce each other forever). t := nextTarget(q.seat) if t < 0 { continue } tu := sides[t].unit bounced := tu.Card bounced.ID = g.newCardID() var keep []Effect for _, ef := range bounced.Effects { if ef.Trigger != TriggerPlay { keep = append(keep, ef) } } bounced.Effects = keep sides[t].stack = append(sides[t].stack, bounced) sides[t].unit = nil emit(BattleEvent{Type: "bounce", Seat: q.seat, Target: t, Card: &bounced, Text: fmt.Sprintf("%s sends %s to the bottom of %s's deck.", q.unit.Card.Name, tu.Card.Name, pname(t))}) anyDeath = true // force a refill before the clash case ActionSpendManaRocks: // Sea Serpent: spend all Mana to throw that many rocks. s := sides[q.seat] choice := s.mana if choice > 0 { s.mana -= choice emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice, Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)}) if t := nextTarget(q.seat); t >= 0 { if killed, _, _ := throwRocks(q.seat, t, choice, nil); killed { anyDeath = true } } } case ActionSpendManaSpook: // Bakunawa: spend all Mana to add that many Spooked to the enemy pet. s := sides[q.seat] choice := s.mana if choice > 0 { s.mana -= choice emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice, Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)}) addAilment(q.seat, AilmentSpooked, choice, false, costName) } case ActionGainAbility: // Abomination: copy a random pet's effects from the highest tier // discard pile. The copied effects join Abomination's own (so its // Faint/Hurt abilities fire later), and any copied Play ability // resolves right now — it's queued onto this same pass. bestTier := 0 for tier, pile := range g.Discards { if tier <= bestTier { continue } for _, dc := range pile { if dc.IsPet() { bestTier = tier break } } } if bestTier > 0 { var pets []Card for _, dc := range g.Discards[bestTier] { if dc.IsPet() { pets = append(pets, dc) } } if len(pets) > 0 { pick := pets[g.battleDraw(len(pets))] q.unit.Card.Effects = append(append([]Effect(nil), q.unit.Card.Effects...), pick.Effects...) emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus, Text: fmt.Sprintf("%s mimics %s's ability.", q.unit.Card.Name, pick.Name)}) // Fire the mimicked Play abilities immediately. Skip another // gainAbility so mimicking can't chain into an infinite loop, // and skip the enter-time shields (armed only on reveal). for _, pe := range pick.Effects { if pe.Trigger != TriggerPlay || pe.Action == ActionGainAbility || pe.Action == ActionShieldSelf || pe.Action == ActionPreventSelf { continue } plays = append(plays, queuedPlay{seat: q.seat, unit: q.unit, effect: pe}) } } } } } // 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: the two sides' pets trade blows. A is the first player's side. ua, ub := sides[0].unit, sides[1].unit // Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied // to the defender inside hitUnit); a Manticore boosts enemy ailments. powA := max(0, ua.Power()-effAilment(0, ua.Spooked)) powB := max(0, ub.Power()-effAilment(1, ub.Spooked)) 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 (both 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 side, s := range sides { if s.canField() { if winner >= 0 { winner = -1 // both still standing: a stalemate draw break } winner = side } } // The winner leaves this function as a seat at the table, the one piece of // the result that means anything outside the battle. if winner >= 0 { res.WinnerSeat = seats[winner] // The last round is worth double. 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) res.ManaAfter = make([]int, n) res.NextRoundApples = 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 res.ManaAfter[seat] = s.mana res.NextRoundApples[seat] = s.nextRoundApples } return res }