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 Shield int `json:"shield"` // hits that will be fully prevented (Gorilla, Melon) } func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } // activePerk returns the perk this unit benefits from: only the // last-applied perk counts when several are attached. func (u *BattleUnit) activePerk() *Card { for i := len(u.Foods) - 1; i >= 0; i-- { if u.Foods[i].Perk { return &u.Foods[i] } } return nil } // effects returns the unit's live effects: its own plus its active perk's. func (u *BattleUnit) effects() []Effect { effs := u.Card.Effects if perk := u.activePerk(); perk != nil { effs = append(append([]Effect(nil), effs...), perk.Effects...) } return effs } // prevention totals the unit's passive per-attack damage reduction (Garlic). func (u *BattleUnit) prevention() int { total := 0 for _, e := range u.effects() { if e.Trigger == TriggerPassive && e.Action == ActionPreventDamage { total += e.count() } } return total } // hasKnockout reports whether the unit's clash attacks KO (Scorpion). func (u *BattleUnit) hasKnockout() bool { for _, e := range u.effects() { if e.Trigger == TriggerPassive && e.Action == ActionKnockout { return true } } return false } // appleCount counts apples among the given foods. func appleCount(foods []Card) int { n := 0 for _, f := range foods { if f.Food == FoodApple { n++ } } return n } // isBee reports whether a card is a summoned Bee. func isBee(c Card) bool { return c.IsPet() && c.Name == "Bee" } // BattleEvent is one step of the battle, in order, for clients to animate. type BattleEvent struct { // "prep": Card starts the battle already in play for Seat (Monkey). // "reveal": Seat flipped Card off their stack (food or pet). // "summon": an effect put Card on top of Seat's stack. // "mill": Card was discarded off the top of Seat's stack (Chili). // "rock": Seat's pet threw rocks at Target's pet in play. // "clash": the pets in play traded blows. // "shield": Seat's pet blocked a hit entirely. // "strip": Seat's pet discarded all of Target's pet's foods (Skunk). // "steal": Seat's pet stole Count apples from Target's pet (Wolverine). // "eat": Seat's pet ate apples; Bonus is its new total. // "heal": Seat's pet healed; DamageAfter is its new damage total. Type string `json:"type"` // Seat/Target must NOT be omitempty: 0 is a valid seat (the first // player) and dropping it makes the client read sides[undefined]. Seat int `json:"seat"` Target int `json:"target"` Card *Card `json:"card,omitempty"` Count int `json:"count,omitempty"` // clash: per-seat damage totals / deaths after the exchange. Damage []int `json:"damage,omitempty"` Died []bool `json:"died,omitempty"` // rock: individual die faces rolled (each 0, 1, or 2), for the dice // animation; Roll is their sum. Dice []int `json:"dice,omitempty"` // rock: dice total rolled; rock/strip/steal: target pet's fate. Roll int `json:"roll"` DamageAfter int `json:"damageAfter"` TargetDied bool `json:"targetDied,omitempty"` // eat: the pet's power bonus after eating. Bonus int `json:"bonus"` // Text is a human-readable description of this step for the event log, // e.g. "Ant's faint effect summons a Bee." Empty for steps not worth a // line (they still animate). Text string `json:"text,omitempty"` } // BattleResult is the full, public record of one round's battle. type BattleResult struct { Round int `json:"round"` StackSizes []int `json:"stackSizes"` // starting deck size per seat // Lineups is each seat's arranged deck at battle start (top of deck // first). Public so players can review the whole matchup — including the // opponent's cards — during and after the fight. Lineups [][]Card `json:"lineups,omitempty"` Events []BattleEvent `json:"events"` WinnerSeat int `json:"winnerSeat"` // -1 = draw Trophies int `json:"trophies"` // awarded to the winner } // setAsideRocks is a fainted pet's pending rock payout. type setAsideRocks struct { dice int everyone bool // Badger: hits every active pet, the owner's own included } // 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 []int // Snake: on every own pet play lastPetRocks []int // Crocodile: when the enemy plays their last pet } // 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 } // 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) } // effectCount resolves an effect's final count: base × Per statistic, // limited by Cap. func effectCount(e Effect, s *battleSide, u *BattleUnit) 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() } 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. func (g *Game) resolveBattle() { 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...)} sides[p.Seat] = s res.StackSizes[p.Seat] = len(p.Deck) res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) } // 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) } } // 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() { apple := g.newApple() sides[p.Seat].pending = append(sides[p.Seat].pending, apple) emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple, Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))}) } } } } } 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)}) } mintFor := func(kind string) Card { if kind == "bee" { return g.newBee() } return g.newApple() } // allowed gates battle-time effects on their conditions. allowed := func(e Effect, u *BattleUnit) bool { if g.Round < e.MinRound { return false } switch e.Condition { case ConditionHasPerk: return u != nil && u.activePerk() != nil case ConditionTripled: return false // shop-time condition; never true in battle } return true } // hitUnit applies one attack against a seat's pet: shields block the // whole hit, Garlic shaves per-attack damage. Returns damage dealt and // whether a shield blocked it. hitUnit := func(seat, amount int) (dealt int, blocked bool) { u := sides[seat].unit if u == nil || amount <= 0 { return 0, false } if u.Shield > 0 { u.Shield-- return 0, true } if sides[seat].shields > 0 { sides[seat].shields-- return 0, true } dealt = max(0, amount-u.prevention()) u.Damage += dealt return dealt, false } // 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++ } for _, e := range u.effects() { if e.Trigger != TriggerFaint || !allowed(e, u) { continue } switch e.Action { case ActionSummonTop: target := seat if e.Target == "enemy" { target = (seat + 1) % n } for range effectCount(e, s, u) { summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name)) } 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 ActionDelayedRocks: s.oneShotRocks = append(s.oneShotRocks, setAsideRocks{dice: e.count(), everyone: e.Target == "all"}) case ActionRecurringRocks: s.recurringRocks = append(s.recurringRocks, e.count()) case ActionEnemyLastPetRocks: s.lastPetRocks = append(s.lastPetRocks, e.count()) case ActionShieldNext: s.shields += e.count() case ActionBeeAura: s.beeBonus += e.count() case ActionPetAura: s.petBonus += e.count() } } // 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), os.unit.Damage) if healed > 0 { os.unit.Damage -= healed emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage, Text: fmt.Sprintf("%s heals %d after an enemy faints.", os.unit.Card.Name, healed)}) } } } } // hurt fires Hurt effects on a pet that was damaged and survived. hurt := func(seat int, u *BattleUnit) { if !u.Alive() { return } for _, e := range u.effects() { if e.Trigger != TriggerHurt || !allowed(e, u) { continue } switch e.Action { case ActionEatApple: for range effectCount(e, sides[seat], u) { 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) { summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name)) } case ActionShieldSelf: u.Shield += e.count() } } } // throwRocks rolls `dice` rock dice against one seat's pet. Rocks are // not "attacks with" the pet, so no knockout applies. Reports a kill. throwRocks := func(from, target, dice int) (killed bool) { tu := sides[target].unit if tu == nil { return false } roll := 0 faces := make([]int, dice) for i := range dice { faces[i] = g.rollRockDie() roll += faces[i] } dealt, blocked := hitUnit(target, roll) died := !tu.Alive() thrower := pname(from) if su := sides[from].unit; su != nil { thrower = su.Card.Name } var rockTxt string switch { case roll == 0: rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name) case died: rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll) default: rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll) } emit(BattleEvent{ Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces, DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt, }) if blocked { emit(BattleEvent{Type: "shield", Seat: target, Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)}) } if died { faint(target, tu) sides[target].unit = nil return true } if dealt > 0 { hurt(target, tu) } return false } // nextTarget finds the seat whose pet a standard enemy-directed play // effect hits. nextTarget := func(from int) int { for off := 1; off < n; off++ { cand := (from + off) % n if sides[cand].unit != nil { return cand } } return -1 } // The exchange loop terminates: every clash kills at least one pet or // is a detected stalemate, and summons/auras are finite. The guard is // just insurance as effects get richer. // // clashStreak counts how many times the current pair has clashed without // either fainting. Some effects (e.g. eat-an-apple-when-hurt) can make two // pets un-killable, looping forever; if they trade blows deadlockLimit // times in a row, both drop so the battle can proceed. It resets whenever // the matchup changes (a fresh pet enters). const deadlockLimit = 9 clashStreak := 0 for range 10_000 { // Reveal until every side has a pet in play or runs out. Play // effects queue up and resolve after all reveals (simultaneous). var plays []queuedPlay newlyPlayed := make([]bool, n) for _, seat := range seatOrder { s := sides[seat] for s.unit == nil && len(s.stack) > 0 { c := s.stack[0] s.stack = s.stack[1:] if c.IsFood() { emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Text: fmt.Sprintf("%s's %s is set aside for the next pet.", pname(seat), c.Name)}) s.pending = append(s.pending, c) continue } u := &BattleUnit{Card: c, Foods: s.pending} u.Bonus += appleCount(s.pending) u.Bonus += s.petBonus if isBee(c) { u.Bonus += s.beeBonus } // Pets carry their starting bonus (foods + auras) in the // reveal event so clients can display it directly. revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name) if u.Bonus > 0 { revealTxt = fmt.Sprintf("%s's %s enters the fray (+%d).", pname(seat), c.Name, u.Bonus) } emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt}) s.pending = nil s.unit = u newlyPlayed[seat] = true // Set-aside payouts fire before the new pet's own play // effects. for _, r := range s.oneShotRocks { plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone, effect: Effect{Action: ActionThrowRock, Count: r.dice}}) } s.oneShotRocks = nil for _, dice := range s.recurringRocks { plays = append(plays, queuedPlay{seat: seat, effect: Effect{Action: ActionThrowRock, Count: dice}}) } for _, e := range u.effects() { if e.Trigger != TriggerPlay { continue } // Shields apply the instant the pet enters play, ahead // of any queued rocks (Melon). if e.Action == ActionShieldSelf { u.Shield += e.count() continue } plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) } } } // 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 _, dice := range os.lastPetRocks { plays = append(plays, queuedPlay{seat: other, effect: Effect{Action: ActionThrowRock, Count: dice}}) } 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 } } // Battle over? A side that couldn't field a pet is out; no further // effects resolve. anyOut := false for _, s := range sides { if s.unit == nil { anyOut = true } } if anyOut { break } // Resolve play effects. Any of these can faint a pet before the // clash. anyDeath := false for _, q := range plays { // Effects sourced from a specific unit fizzle if it's gone. if q.unit != nil && sides[q.seat].unit != q.unit { continue } if q.unit != nil && !allowed(q.effect, q.unit) { continue } switch q.effect.Action { case ActionThrowRock: dice := q.effect.count() if q.unit != nil { dice = effectCount(q.effect, sides[q.seat], q.unit) } if q.everyone { for seat := range sides { if throwRocks(q.seat, seat, dice) { anyDeath = true } } } else if t := nextTarget(q.seat); t >= 0 { if throwRocks(q.seat, t, dice) { 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) { summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name)) } case ActionEatApple: count := effectCount(q.effect, sides[q.seat], q.unit) 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)}) } } } if anyDeath { continue // refill before any clash } // Clash. Two-player for now; >2-player battle pairings come later // (the surrounding state is already per-seat). ua, ub := sides[0].unit, sides[1].unit powA, powB := ua.Power(), ub.Power() dealtA, blockedA := hitUnit(0, powB) dealtB, blockedB := 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 blockedA { emit(BattleEvent{Type: "shield", Seat: 0, Text: fmt.Sprintf("%s blocks the hit with a shield.", ua.Card.Name)}) } if blockedB { emit(BattleEvent{Type: "shield", Seat: 1, Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)}) } if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB { break // stalemate: nothing can ever change } dealt := []int{dealtA, dealtB} for seat, u := range []*BattleUnit{ua, ub} { if !u.Alive() { faint(seat, u) sides[seat].unit = nil } else if dealt[seat] > 0 { hurt(seat, u) } } } // A single side still holding a pet in play wins; anything else // (everyone out, or a stalemate with pets on both sides) is a draw. winner := -1 for seat, s := range sides { if s.unit != nil { if winner >= 0 { winner = -1 // stalemate / >2-player safety break } winner = seat } } res.WinnerSeat = winner if winner >= 0 { res.Trophies = 1 if g.Round == MaxRounds { res.Trophies = 2 } 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; the // "other player" is unambiguous only at n == 2.) if winner == g.PrioritySeat { g.PrioritySeat = (winner + 1) % n } } g.Battle = res g.Phase = PhaseBattle // Tagged "result" so the client can hold it back until the replay finishes // (the outcome is known now, but showing it early would spoil the battle). if winner < 0 { g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: "result", Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)}) } else { g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: "result", Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)}) } for _, p := range g.Players { p.Ready = false } }