Add pets for tiers 4-6.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:20 -04:00
parent 8d43bbf61e
commit 10ac457e2e
11 changed files with 1334 additions and 163 deletions
+362 -91
View File
@@ -5,8 +5,9 @@ package game
type BattleUnit struct {
Card Card `json:"card"`
Foods []Card `json:"foods,omitempty"`
Bonus int `json:"bonus"` // total power added by foods (and eating)
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 }
@@ -43,15 +44,25 @@ func (u *BattleUnit) prevention() int {
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
// 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
}
}
dealt := max(0, amount-u.prevention())
u.Damage += dealt
return dealt
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.
@@ -59,19 +70,26 @@ 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 int `json:"seat,omitempty"`
Target int `json:"target,omitempty"`
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 and the target pet's resulting state.
// rock: dice total rolled; rock/strip/steal: target pet's fate.
Roll int `json:"roll"`
DamageAfter int `json:"damageAfter,omitempty"`
TargetDied bool `json:"targetDied,omitempty"`
@@ -90,21 +108,64 @@ type BattleResult struct {
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 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
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 pet whose play queued this (must still be in play for eats)
effect Effect
badger bool // delayedRocks payout: hits EVERY active pet, own included
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,
@@ -113,37 +174,100 @@ type queuedPlay struct {
// 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
// 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 {
sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)}
s := &battleSide{stack: append([]Card(nil), p.Deck...)}
sides[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck)
}
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// 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, and tracks fainted bees.
faint := func(seat int, u *BattleUnit) {
// 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) {
sides[seat].beesFainted++
s.beesFainted++
}
for _, e := range u.effects() {
if e.Trigger != TriggerFaint {
if e.Trigger != TriggerFaint || !allowed(e, u) {
continue
}
switch e.Action {
@@ -152,12 +276,8 @@ func (g *Game) resolveBattle() {
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())
}
for range effectCount(e, s, u) {
summon(target, mintFor(e.Card))
}
case ActionRecycleApples:
recycled := 0
@@ -168,7 +288,34 @@ func (g *Game) resolveBattle() {
}
}
case ActionDelayedRocks:
sides[seat].delayedRocks = append(sides[seat].delayedRocks, e.count())
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})
}
}
}
}
@@ -179,30 +326,28 @@ func (g *Game) resolveBattle() {
return
}
for _, e := range u.effects() {
if e.Trigger != TriggerHurt {
if e.Trigger != TriggerHurt || !allowed(e, u) {
continue
}
switch e.Action {
case ActionEatApple:
for range e.count() {
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 e.count() {
if e.Card == "bee" {
summon(seat, g.newBee())
} else {
summon(seat, g.newApple())
}
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 target's pet, handling
// prevention, death (with faint), and hurt. Reports whether it killed.
// 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 {
@@ -212,12 +357,15 @@ func (g *Game) resolveBattle() {
for range dice {
roll += g.rollRockDie()
}
dealt := tu.takeHit(roll)
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
@@ -229,41 +377,95 @@ func (g *Game) resolveBattle() {
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 are finite. The guard is just
// insurance as effects get richer.
// 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:]
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c})
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}
for _, f := range s.pending {
if f.Food == FoodApple {
u.Bonus++
}
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
// 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,
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}})
}
s.delayedRocks = nil
for _, e := range u.effects() {
if e.Trigger == TriggerPlay {
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
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
}
}
}
@@ -280,41 +482,96 @@ func (g *Game) resolveBattle() {
break
}
// Resolve play effects. Rocks land before the clash and can faint
// pets outright.
rockDeath := false
// 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:
if q.badger {
// At EACH active pet, the owner's own included.
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, q.effect.count()) {
rockDeath = true
if throwRocks(q.seat, seat, dice) {
anyDeath = 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
} else if t := nextTarget(q.seat); t >= 0 {
if throwRocks(q.seat, t, dice) {
anyDeath = true
}
}
case ActionEatApple:
// Skip if the eater already died (e.g. to Badger rocks).
if sides[q.seat].unit != q.unit {
case ActionStripFoods:
t := nextTarget(q.seat)
if t < 0 {
continue
}
count := q.effect.count()
if q.effect.Per == PerFaintedBees {
count *= sides[q.seat].beesFainted
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())
@@ -324,21 +581,35 @@ func (g *Game) resolveBattle() {
}
}
}
if rockDeath {
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
dealtA := ua.takeHit(ub.Power())
dealtB := ub.takeHit(ua.Power())
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 ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 {
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}