package game // 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 (and eating) Damage int `json:"damage"` // damage markers accumulated this battle } 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 } // takeHit applies one attack of the given strength and returns the damage // actually dealt after prevention. func (u *BattleUnit) takeHit(amount int) int { if amount <= 0 { return 0 } dealt := max(0, amount-u.prevention()) u.Damage += dealt return dealt } // 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 { // "reveal": Seat flipped Card off their stack (food or pet). // "summon": an effect put Card on top of Seat's stack. // "rock": Seat's pet threw rocks at Target's pet in play. // "clash": the pets in play traded blows. // "eat": Seat's pet ate apples; Bonus is its new total. Type string `json:"type"` Seat int `json:"seat,omitempty"` Target int `json:"target,omitempty"` Card *Card `json:"card,omitempty"` // clash: per-seat damage totals / deaths after the exchange. Damage []int `json:"damage,omitempty"` Died []bool `json:"died,omitempty"` // rock: dice total rolled and the target pet's resulting state. Roll int `json:"roll"` DamageAfter int `json:"damageAfter,omitempty"` TargetDied bool `json:"targetDied,omitempty"` // eat: the pet's power bonus after eating. Bonus int `json:"bonus,omitempty"` } // BattleResult is the full, public record of one round's battle. Only what // was revealed appears in Events; the rest of the winner's stack stays // hidden. type BattleResult struct { Round int `json:"round"` StackSizes []int `json:"stackSizes"` // starting deck size per seat Events []BattleEvent `json:"events"` WinnerSeat int `json:"winnerSeat"` // -1 = draw Trophies int `json:"trophies"` // awarded to the winner } // 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 and waiting for a pet unit *BattleUnit beesFainted int // friendly bees fainted so far (Dog) delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays } // queuedPlay is a play-time effect waiting to resolve after reveals. type queuedPlay struct { seat int unit *BattleUnit // the pet whose play queued this (must still be in play for eats) effect Effect badger bool // delayedRocks payout: hits EVERY active pet, own included } // 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 roll dice (faces // 0,0,1,1,2,2) and 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. Garlic prevents 1 damage from every attack that // hits its pet; a clash that deals no damage to anyone 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)} sides := make([]*battleSide, n) for _, p := range g.Players { sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)} res.StackSizes[p.Seat] = len(p.Deck) } emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } summon := func(seat int, c Card) { s := sides[seat] s.stack = append([]Card{c}, s.stack...) emit(BattleEvent{Type: "summon", Seat: seat, Card: &c}) } // faint fires the unit's faint effects (its own and its perk's) in // effect order, and tracks fainted bees. faint := func(seat int, u *BattleUnit) { if isBee(u.Card) { sides[seat].beesFainted++ } for _, e := range u.effects() { if e.Trigger != TriggerFaint { continue } switch e.Action { case ActionSummonTop: target := seat if e.Target == "enemy" { target = (seat + 1) % n } for range e.count() { if e.Card == "bee" { summon(target, g.newBee()) } else { summon(target, g.newApple()) } } case ActionRecycleApples: recycled := 0 for _, f := range u.Foods { if f.Food == FoodApple && recycled < e.count() { summon(seat, f) recycled++ } } case ActionDelayedRocks: sides[seat].delayedRocks = append(sides[seat].delayedRocks, e.count()) } } } // 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 { continue } switch e.Action { case ActionEatApple: for range e.count() { u.Foods = append(u.Foods, g.newApple()) u.Bonus++ } emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus}) case ActionSummonTop: for range e.count() { if e.Card == "bee" { summon(seat, g.newBee()) } else { summon(seat, g.newApple()) } } } } } // throwRocks rolls `dice` rock dice against one target's pet, handling // prevention, death (with faint), and hurt. Reports whether it killed. throwRocks := func(from, target, dice int) (killed bool) { tu := sides[target].unit if tu == nil { return false } roll := 0 for range dice { roll += g.rollRockDie() } dealt := tu.takeHit(roll) died := !tu.Alive() emit(BattleEvent{ Type: "rock", Seat: from, Target: target, Roll: roll, DamageAfter: tu.Damage, TargetDied: died, }) if died { faint(target, tu) sides[target].unit = nil return true } if dealt > 0 { hurt(target, tu) } return false } // The exchange loop terminates: every clash kills at least one pet or // is a detected stalemate, and summons are finite. The guard is just // insurance as effects get richer. 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 for seat, s := range sides { for s.unit == nil && len(s.stack) > 0 { c := s.stack[0] s.stack = s.stack[1:] emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c}) if c.IsFood() { s.pending = append(s.pending, c) continue } u := &BattleUnit{Card: c, Foods: s.pending} for _, f := range s.pending { if f.Food == FoodApple { u.Bonus++ } } s.pending = nil s.unit = u // Set-aside Badgers pay out when the next pet plays, // before that pet's own play effects. for _, dice := range s.delayedRocks { plays = append(plays, queuedPlay{seat: seat, unit: u, badger: true, effect: Effect{Action: ActionThrowRock, Count: dice}}) } s.delayedRocks = nil for _, e := range u.effects() { if e.Trigger == TriggerPlay { plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) } } } } // 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. Rocks land before the clash and can faint // pets outright. rockDeath := false for _, q := range plays { switch q.effect.Action { case ActionThrowRock: if q.badger { // At EACH active pet, the owner's own included. for seat := range sides { if throwRocks(q.seat, seat, q.effect.count()) { rockDeath = true } } } else { target := -1 for off := 1; off < n; off++ { cand := (q.seat + off) % n if sides[cand].unit != nil { target = cand break } } if target >= 0 && throwRocks(q.seat, target, q.effect.count()) { rockDeath = true } } case ActionEatApple: // Skip if the eater already died (e.g. to Badger rocks). if sides[q.seat].unit != q.unit { continue } count := q.effect.count() if q.effect.Per == PerFaintedBees { count *= sides[q.seat].beesFainted } 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}) } } } if rockDeath { 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 dealtA := ua.takeHit(ub.Power()) dealtB := ub.takeHit(ua.Power()) emit(BattleEvent{ Type: "clash", Damage: []int{ua.Damage, ub.Damage}, Died: []bool{!ua.Alive(), !ub.Alive()}, }) if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 { 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 } g.Battle = res g.Phase = PhaseBattle for _, p := range g.Players { p.Ready = false } }