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

1815 lines
64 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game
import (
"fmt"
)
// 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 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"`
// ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
// is apples each seat banked for next round's hand (Skeleton Dog).
ManaAfter []int `json:"manaAfter,omitempty"`
NextRoundApples []int `json:"nextRoundApples,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
// --- 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
}
// 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
// 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 res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
p.Mana = res.ManaAfter[p.Seat]
}
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
p.NextRoundApples += res.NextRoundApples[p.Seat]
}
}
}
// 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{}}
// 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[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))})
}
// 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. 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
// 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 (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)
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
}