Files
super-auto-pets-board-game/internal/game/battle.go
T
2026-07-23 00:32:57 -04:00

655 lines
19 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
// 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: 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"`
}
// 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
}
// 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)}
sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
for _, p := range g.Players {
s := &battleSide{stack: append([]Card(nil), p.Deck...)}
sides[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck)
}
// 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})
}
}
}
}
}
summon := func(seat int, c Card) {
s := sides[seat]
s.stack = append([]Card{c}, s.stack...)
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c})
}
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))
}
case ActionRecycleApples:
recycled := 0
for _, f := range u.Foods {
if f.Food == FoodApple && recycled < e.count() {
summon(seat, f)
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})
}
}
}
}
// 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})
case ActionSummonTop:
for range effectCount(e, sides[seat], u) {
summon(seat, mintFor(e.Card))
}
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
for range dice {
roll += g.rollRockDie()
}
dealt, blocked := hitUnit(target, roll)
died := !tu.Alive()
emit(BattleEvent{
Type: "rock", Seat: from, Target: target, Roll: roll,
DamageAfter: tu.Damage, TargetDied: died,
})
if blocked {
emit(BattleEvent{Type: "shield", Seat: target})
}
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.
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, s := range sides {
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})
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.
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus})
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 sides {
if !newlyPlayed[seat] {
continue
}
for other, os := range sides {
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
}
}
}
// 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})
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})
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})
}
case ActionSummonTop:
for range effectCount(q.effect, sides[q.seat], q.unit) {
summon(q.seat, mintFor(q.effect.Card))
}
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})
}
}
}
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())
}
emit(BattleEvent{
Type: "clash",
Damage: []int{ua.Damage, ub.Damage},
Died: []bool{!ua.Alive(), !ub.Alive()},
})
if blockedA {
emit(BattleEvent{Type: "shield", Seat: 0})
}
if blockedB {
emit(BattleEvent{Type: "shield", Seat: 1})
}
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
}
g.Battle = res
g.Phase = PhaseBattle
for _, p := range g.Players {
p.Ready = false
}
}