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}
+209 -36
View File
@@ -23,9 +23,12 @@ const (
// Food identifiers.
const (
FoodApple = "apple"
FoodHoney = "honey"
FoodGarlic = "garlic"
FoodApple = "apple"
FoodHoney = "honey"
FoodGarlic = "garlic"
FoodPineapple = "pineapple"
FoodChili = "chili"
FoodMelon = "melon"
)
// EffectTrigger is when an effect fires.
@@ -41,6 +44,10 @@ const (
// TriggerBattlePrep fires right after the shop phase ends, as players
// begin ordering their decks.
TriggerBattlePrep EffectTrigger = "battlePrep"
// TriggerEnemyFaint fires on a pet in play whenever an enemy pet faints.
TriggerEnemyFaint EffectTrigger = "enemyFaint"
// TriggerEnemyPlay fires on a pet in play whenever the enemy plays a pet.
TriggerEnemyPlay EffectTrigger = "enemyPlay"
// TriggerPassive marks always-on effects (e.g. Garlic's damage
// prevention); they're consulted contextually rather than fired.
TriggerPassive EffectTrigger = "passive"
@@ -73,13 +80,65 @@ const (
// apples back on top of its owner's deck.
ActionRecycleApples EffectAction = "recycleApples"
// ActionDelayedRocks (faint) sets the pet aside: when its owner plays
// their next pet, it throws Count rocks at EACH active pet — the
// enemy's and the owner's own.
// their next pet, it throws Count rocks. With Target "all" (Badger)
// the volley hits EACH active pet — the owner's own included;
// otherwise it targets the enemy pet as usual (Blowfish).
ActionDelayedRocks EffectAction = "delayedRocks"
// ActionRecurringRocks (faint) sets the pet aside: EVERY time its owner
// plays a pet, it throws Count rocks at the enemy pet (Snake).
ActionRecurringRocks EffectAction = "recurringRocks"
// ActionEnemyLastPetRocks (faint) sets the pet aside: when the enemy
// plays the last pet in their deck, it throws Count rocks (Crocodile).
ActionEnemyLastPetRocks EffectAction = "enemyLastPetRocks"
// ActionShieldNext (faint) sets the pet aside: the next Count times a
// friendly pet is hit, ALL damage from that hit is prevented (Turtle).
ActionShieldNext EffectAction = "shieldNext"
// ActionShieldSelf gives the pet itself Count charges that each prevent
// all damage from one hit (Gorilla on hurt, Melon on play).
ActionShieldSelf EffectAction = "shieldSelf"
// ActionStripFoods (play) discards every food attached to the enemy
// pet in play (losing their buffs and perks, which can faint it).
ActionStripFoods EffectAction = "stripFoods"
// ActionStealApples (play) moves up to Count apples from the enemy pet
// to this pet (Wolverine). Losing apples can faint the victim.
ActionStealApples EffectAction = "stealApples"
// ActionMillEnemy (play) discards cards off the top of the enemy deck
// until a non-Bee pet is showing (Chili).
ActionMillEnemy EffectAction = "millEnemy"
// ActionHeal removes Count damage markers from the pet, if it hasn't
// fainted.
ActionHeal EffectAction = "heal"
// ActionKnockout (passive) makes any pet this pet hurts with its clash
// attack faint outright (Scorpion). Rocks don't count, and a fully
// prevented attack KOs nothing.
ActionKnockout EffectAction = "knockout"
// ActionApplesInPlay (battle prep) starts the battle with Count apples
// already in play, attached to the owner's first pet (Monkey).
ActionApplesInPlay EffectAction = "applesInPlay"
// ActionDoubleApples doubles the apples in the player's hand (Cat).
// Shop-time.
ActionDoubleApples EffectAction = "doubleApples"
// ActionBeeAura (faint) sets the pet aside: the owner's Bees have
// +Count power for the rest of the battle (Turkey).
ActionBeeAura EffectAction = "beeAura"
// ActionPetAura (faint) sets the pet aside: the owner's pets have
// +Count power for the rest of the battle (Mammoth).
ActionPetAura EffectAction = "petAura"
)
// Per multipliers for dynamic effect counts.
const PerFaintedBees = "faintedBees" // × friendly bees fainted this battle
const (
PerFaintedBees = "faintedBees" // × friendly bees fainted this battle
PerFaintedPets = "faintedPets" // × friendly pets fainted this battle
PerEatenApples = "eatenApples" // × apples this pet ate (its attached apples)
PerPower = "power" // × this pet's current power
)
// Effect conditions.
const (
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
ConditionHasPerk = "hasPerk" // this pet has a perk attached
)
// Effect is one trigger→action pair printed on a card.
type Effect struct {
@@ -90,6 +149,10 @@ type Effect struct {
Target string `json:"target,omitempty"` // summonTop: "" (self) | "enemy"
// Per multiplies Count by a battle statistic (e.g. PerFaintedBees).
Per string `json:"per,omitempty"`
// Cap limits the final count when > 0 ("up to N").
Cap int `json:"cap,omitempty"`
// Condition gates the effect (e.g. ConditionTripled).
Condition string `json:"condition,omitempty"`
// MinRound gates the effect to round >= MinRound (0 = always).
MinRound int `json:"minRound,omitempty"`
}
@@ -148,10 +211,7 @@ type foodTemplate struct {
}
// petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1)
// through index 5 for tier 6 (round 6).
//
// Tiers 1-2 are real card data. Tiers 3-6 are placeholders with the same
// structure until their real definitions are provided.
// through index 5 for tier 6 (round 6). All six tiers are real card data.
var petTiers = [MaxRounds][]petTemplate{
{ // Tier 1
{
@@ -253,16 +313,127 @@ var petTiers = [MaxRounds][]petTemplate{
},
{
Name: "Badger", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 2}},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 2, Target: "all"}},
EffectText: "Faint: set aside — when you play your next pet, throw 2 Rocks at each active pet",
},
},
placeholderTier([6]string{"Skunk", "Hippo", "Bison", "Deer", "Squirrel", "Whale"},
[6]int{5, 6, 6, 4, 4, 5}),
placeholderTier([6]string{"Scorpion", "Rhino", "Monkey", "Cow", "Seal", "Shark"},
[6]int{5, 7, 6, 6, 6, 7}),
placeholderTier([6]string{"Leopard", "Boar", "Gorilla", "Mammoth", "Snake", "Tiger"},
[6]int{8, 9, 9, 10, 8, 9}),
{ // Tier 4
{
Name: "Squirrel", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{
{Trigger: TriggerBuy, Action: ActionGainApple, Count: 2},
{Trigger: TriggerSell, Action: ActionGainApple, Count: 2},
},
EffectText: "Buy: add 2 Apples to your hand · Sell: add 2 extra Apples to your hand",
},
{
Name: "Turtle", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionShieldNext}},
EffectText: "Faint: set aside — the next time a friendly pet is hit, prevent all damage",
},
{
Name: "Rooster", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Per: PerEatenApples, Cap: 3}},
EffectText: "Faint: add a Bee on top of your deck for each Apple this pet ate, up to 3",
},
{
Name: "Bison", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionGainApple, Count: 3, Condition: ConditionTripled}},
EffectText: "Battle Prep: if you Tripled during this round's shop, add 3 Apples to your hand",
},
{
Name: "Blowfish", Power: 3, Suits: []Suit{SuitBlue, SuitRed},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 3}},
EffectText: "Faint: set aside — when you play your next pet, throw 3 Rocks",
},
{
Name: "Skunk", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStripFoods}},
EffectText: "Play: discard all Food from the enemy pet",
},
{
Name: "Hippo", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerEnemyFaint, Action: ActionHeal}},
EffectText: "Enemy Faints: if this pet hasn't fainted, heal 1 damage",
},
},
{ // Tier 5
{
Name: "Monkey", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionApplesInPlay, Count: 3}},
EffectText: "Battle Prep: start with 3 Apples in play, attached to your first pet",
},
{
Name: "Rhino", Power: 5, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerEnemyPlay, Action: ActionThrowRock}},
EffectText: "Enemy Played: throw 1 Rock",
},
{
Name: "Crocodile", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionEnemyLastPetRocks, Count: 3}},
EffectText: "Faint: set aside — when the last pet in the enemy deck is played, throw 3 Rocks",
},
{
Name: "Scorpion", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionKnockout}},
EffectText: "This pet KOs any pet it hurts with an attack",
},
{
Name: "Seal", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 3, Condition: ConditionHasPerk}},
EffectText: "Play: if this pet has a Perk, add 3 Apples on top of your deck",
},
{
Name: "Shark", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedPets}},
EffectText: "Play: eats 1 Apple for each friendly fainted pet",
},
{
Name: "Turkey", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionBeeAura}},
EffectText: "Faint: set aside — your Bees have +1 power",
},
},
{ // Tier 6
{
Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf}},
EffectText: "Hurt: the next time this pet is hit, prevent all damage",
},
{
Name: "Fly", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 3}},
EffectText: "Faint: add 3 Bees on top of your deck",
},
{
Name: "Leopard", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerPower}},
EffectText: "Play: throw Rocks equal to this pet's power",
},
{
Name: "Mammoth", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionPetAura}},
EffectText: "Faint: set aside — your pets have +1 power",
},
{
Name: "Cat", Power: 2, Suits: []Suit{SuitYellow, SuitBlue},
Effects: []Effect{
{Trigger: TriggerBuy, Action: ActionGainApple, Count: 2},
{Trigger: TriggerBuy, Action: ActionDoubleApples},
},
EffectText: "Buy: add 2 Apples to your hand, then double your Apples in hand",
},
{
Name: "Snake", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionRecurringRocks, Count: 2}},
EffectText: "Faint: set aside — each time you play a pet, throw 2 Rocks",
},
{
Name: "Wolverine", Power: 5, Suits: []Suit{SuitBlue, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStealApples, Count: 3}},
EffectText: "Play: steal up to 3 Apples from the enemy pet",
},
},
}
// foodTiers defines the food cards mixed into each tier's shop deck.
@@ -282,25 +453,27 @@ var foodTiers = [MaxRounds][]foodTemplate{
EffectText: "Every attack that hits this pet deals 1 less damage",
},
},
{}, {}, {},
}
// placeholderTier mirrors tier 1's suit distribution (each suit appears on 4
// of the 12 cards) for pets whose real definitions aren't in yet.
func placeholderTier(names [6]string, powers [6]int) []petTemplate {
suitPairs := [6][]Suit{
{SuitBlue, SuitYellow},
{SuitRed, SuitBlue},
{SuitYellow, SuitBlue},
{SuitYellow, SuitRed},
{SuitRed, SuitBlue},
{SuitYellow, SuitRed},
}
tier := make([]petTemplate, 6)
for i := range names {
tier[i] = petTemplate{Name: names[i], Power: powers[i], Suits: suitPairs[i]}
}
return tier
{ // Tier 4
{
Name: "Pineapple", Food: FoodPineapple, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 3}},
EffectText: "Play: throw 3 Rocks",
},
},
{ // Tier 5
{
Name: "Chili", Food: FoodChili, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionMillEnemy}},
EffectText: "Play: discard cards from the top of the enemy deck until a non-Bee pet is showing",
},
},
{ // Tier 6
{
Name: "Melon", Food: FoodMelon, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf}},
EffectText: "The first time this pet is hit, prevent all damage",
},
},
}
// newCardID mints a unique card ID within the game.
+49
View File
@@ -0,0 +1,49 @@
package game
import "testing"
// TestRandomBattlesTerminate throws thousands of random decks (drawn from
// every tier's real cards, plus apples, bees, and perks) at the battle
// resolver, checking it always terminates with a sane event log and a
// consistent winner. This is the safety net for effect interactions no
// hand-written scenario covers.
func TestRandomBattlesTerminate(t *testing.T) {
names := []string{}
for _, tier := range petTiers {
for _, tmpl := range tier {
names = append(names, tmpl.Name)
}
}
foods := []string{"Honey", "Garlic", "Pineapple", "Chili", "Melon"}
for i := range 3000 {
g, _, _ := testGame(t)
buildDeck := func() []Card {
deck := make([]Card, 0, 10)
for range 1 + randInt(6) {
switch randInt(5) {
case 0:
deck = append(deck, g.newApple())
case 1:
deck = append(deck, g.realFood(t, foods[randInt(len(foods))]))
case 2:
deck = append(deck, g.newBee())
default:
deck = append(deck, g.realPet(t, names[randInt(len(names))]))
}
}
return deck
}
g.Round = 1 + randInt(MaxRounds)
res := forceBattle(t, g, buildDeck(), buildDeck())
if len(res.Events) > 2000 {
t.Fatalf("battle %d produced a runaway event log (%d events)", i, len(res.Events))
}
if res.WinnerSeat < -1 || res.WinnerSeat > 1 {
t.Fatalf("battle %d: bogus winner %d", i, res.WinnerSeat)
}
if res.WinnerSeat >= 0 && res.Trophies < 1 {
t.Fatalf("battle %d: winner without trophies", i)
}
}
}
+21 -1
View File
@@ -45,6 +45,9 @@ type Player struct {
Trophies int `json:"trophies"`
Ready bool `json:"ready"` // arrange submitted / battle acknowledged
Connected bool `json:"connected"`
// TripledThisRound records whether the player used the Triple (trade-in)
// action during the current round's shop (Bison's Battle Prep).
TripledThisRound bool `json:"tripledThisRound"`
}
// PetCount counts pet cards in the player's deck.
@@ -207,6 +210,7 @@ func (g *Game) startShopRound() {
for _, p := range g.Players {
p.Coins = CoinsPerRound
p.Ready = false
p.TripledThisRound = false
}
g.ShopRow = make([]Card, ShopRowSize)
for i := range g.ShopRow {
@@ -285,12 +289,17 @@ func (g *Game) Sell(playerID string, cardIDs []string) error {
return nil
}
// applyShopTrigger fires a shop-time trigger (buy/sell/triple) on one card.
// applyShopTrigger fires a shop-time trigger (buy/sell/triple/battle prep)
// on one card. Battle-time actions on the same trigger (Monkey's
// applesInPlay) are ignored here and handled by the battle resolver.
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
for _, e := range c.Effects {
if e.Trigger != trigger || g.Round < e.MinRound {
continue
}
if e.Condition == ConditionTripled && !p.TripledThisRound {
continue
}
switch e.Action {
case ActionGainApple:
for range e.count() {
@@ -298,6 +307,16 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
}
case ActionRefreshGold:
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
case ActionDoubleApples:
apples := 0
for _, dc := range p.Deck {
if dc.Food == FoodApple {
apples++
}
}
for range apples {
p.Deck = append(p.Deck, g.newApple())
}
}
}
}
@@ -369,6 +388,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
p.Coins--
p.TripledThisRound = true
g.Pending = &PendingTrade{
PlayerID: playerID,
Tier: nextTier,
+3 -1
View File
@@ -1,6 +1,7 @@
package game
import (
"slices"
"testing"
)
@@ -171,7 +172,8 @@ func TestTradeInThreeMatchingSuits(t *testing.T) {
if err := g.TradeChoose(p.ID, 0); err != nil {
t.Fatal(err)
}
if len(p.Deck) != 1 || p.Deck[0].ID != chosen.ID {
// The chosen card joins the deck (its Buy effect may add apples too).
if slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == chosen.ID }) < 0 {
t.Fatal("chosen card should join the deck")
}
deck2 := g.ShopDecks[1]
+545
View File
@@ -0,0 +1,545 @@
package game
import "testing"
// --- Tier 4 ---
func countApplesIn(deck []Card) int {
n := 0
for _, c := range deck {
if c.Food == FoodApple {
n++
}
}
return n
}
func TestSquirrelBuyAndSell(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
g.ShopRow[0] = g.realPet(t, "Squirrel")
if err := g.Buy(p.ID, 0); err != nil {
t.Fatal(err)
}
if countApplesIn(p.Deck) != 2 {
t.Fatalf("buying a Squirrel should add 2 apples, got %d", countApplesIn(p.Deck))
}
// Sell it back on the next turn: 1 base + 2 extra = 3 more apples.
if err := g.Buy(current(g).ID, 0); err != nil { // opponent spends a coin
t.Fatal(err)
}
var squirrelID string
for _, c := range p.Deck {
if c.Name == "Squirrel" {
squirrelID = c.ID
}
}
if err := g.Sell(p.ID, []string{squirrelID}); err != nil {
t.Fatal(err)
}
if countApplesIn(p.Deck) != 5 {
t.Fatalf("selling a Squirrel should add 3 apples (1 base + 2 extra), got %d total", countApplesIn(p.Deck))
}
}
// Turtle's faint shields the next friendly hit completely.
func TestTurtleShieldsNextFriendlyHit(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Turtle"), g.pet("Ally", 3)}, // turtle: 2
[]Card{g.pet("Big", 5)},
)
// Turtle dies to the 5 (Big takes 2). Ally's first hit is fully
// blocked; Ally hits back for 3: Big at 5 damage = dead. Ally wins
// unscathed.
shields := eventsOfType(res, "shield")
if len(shields) != 1 || shields[0].Seat != 0 {
t.Fatalf("expected one shield block for seat 0: %+v", shields)
}
clashes := eventsOfType(res, "clash")
final := clashes[len(clashes)-1]
if final.Damage[0] != 0 || !final.Died[1] {
t.Fatalf("ally should take 0 damage while Big dies: %+v", final)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Pineapple is a perk that gives its pet "Play: throw 3 Rocks".
func TestPineapplePerkThrowsRocks(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realFood(t, "Pineapple"), g.pet("Holder", 2)},
[]Card{g.pet("Tank", 3)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 3 || !rocks[0].TargetDied {
t.Fatalf("pineapple should throw 3 rocks and kill the 3-power tank: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Rooster summons a bee per apple it ate, capped at 3.
func TestRoosterBeesPerEatenAppleCapped(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.realPet(t, "Rooster")}, // 4+4=8
[]Card{g.pet("Colossus", 9)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 3 {
t.Fatalf("rooster ate 4 apples but summons cap at 3 bees: %+v", summons)
}
for _, s := range summons {
if s.Card.Name != "Bee" {
t.Fatalf("rooster summons bees: %+v", s)
}
}
// Colossus (9) takes 8 from rooster, dies to the first bee; seat 0
// still has bees to field and wins.
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Bison's Battle Prep needs a Triple this round.
func TestBisonBattlePrepRequiresTriple(t *testing.T) {
for _, tripled := range []bool{false, true} {
g, p1, _ := testGame(t)
p1.Deck = append(p1.Deck, g.realPet(t, "Bison"))
if tripled {
var ids []string
for range 3 {
c := g.pet("Fodder", 1)
c.Suit = SuitBlue
p1.Deck = append(p1.Deck, c)
ids = append(ids, c.ID)
}
g.Turn = p1.Seat
if err := g.TradeStart(p1.ID, ids); err != nil {
t.Fatal(err)
}
if err := g.TradeChoose(p1.ID, 0); err != nil {
t.Fatal(err)
}
}
spendAllCoins(t, g)
want := 0
if tripled {
want = 3
}
// Count only apples (the trade-chosen card may have added some via
// its own Buy effect — exclude by counting before/after? Instead,
// require at least; strict equality only in the no-trade case).
got := countApplesIn(p1.Deck)
if !tripled && got != want {
t.Fatalf("no triple: bison should add no apples, got %d", got)
}
if tripled && got < want {
t.Fatalf("tripled: bison should add 3 apples, got %d", got)
}
}
}
// Blowfish volleys 3 rocks at the enemy only when the next pet plays.
func TestBlowfishDelayedRocksEnemyOnly(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Blowfish"), g.pet("Next", 2)}, // blowfish: 3
[]Card{g.pet("Tank", 6)},
)
// Blowfish dies (takes 6), Tank keeps 3 damage. Next plays → 3 rocks
// at the enemy only: Tank at 6 damage = dead. Seat 0 wins; Next is
// untouched.
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Target != 1 || rocks[0].Roll != 3 {
t.Fatalf("blowfish should volley the enemy only: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Skunk strips the enemy pet's foods; a wounded veteran can faint from the
// power loss.
func TestSkunkStripCanKill(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Chip", 3), g.realPet(t, "Skunk")},
[]Card{g.newApple(), g.newApple(), g.pet("Wall", 2)}, // 2+2 = 4 power
)
// Chip (3) dies to Wall (4); Wall carries 3 damage (1 health). Skunk
// plays and strips both apples: Wall drops to 2 power with 3 damage —
// it faints on the spot, no clash needed.
strips := eventsOfType(res, "strip")
if len(strips) != 1 || strips[0].Target != 1 || !strips[0].TargetDied {
t.Fatalf("strip should kill the buffed veteran: %+v", strips)
}
if len(eventsOfType(res, "clash")) != 1 {
t.Fatal("only the first clash should happen")
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Hippo heals 1 damage whenever an enemy pet faints.
func TestHippoHealsOnEnemyFaint(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Hippo")}, // 4 power
[]Card{g.pet("Chip", 2), g.pet("Chip2", 2)},
)
heals := eventsOfType(res, "heal")
if len(heals) != 2 {
t.Fatalf("hippo should heal on each enemy faint: %+v", heals)
}
if heals[0].DamageAfter != 1 || heals[1].DamageAfter != 2 {
t.Fatalf("hippo damage should go 2→1 then 3→2: %+v", heals)
}
if res.WinnerSeat != 0 {
t.Fatalf("hippo should win, got %d", res.WinnerSeat)
}
}
// --- Tier 5 ---
// Monkey starts the battle with 3 apples attached to the first pet.
func TestMonkeyApplesInPlay(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("First", 2), g.realPet(t, "Monkey")}, // monkey: 3
[]Card{g.pet("Enemy", 5)},
)
preps := eventsOfType(res, "prep")
if len(preps) != 3 || preps[0].Seat != 0 {
t.Fatalf("monkey should start 3 apples in play for seat 0: %+v", preps)
}
// First fights at 2+3=5 vs 5: both die. Monkey (3) then beats nothing —
// enemy is out. Seat 0 wins.
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Rhino rocks every enemy pet as it's played.
func TestRhinoRocksEnemyPlays(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Rhino")}, // 5 power
[]Card{g.pet("A", 1), g.pet("B", 6)},
)
// A is played → rhino rocks it dead (1 damage ≥ 1 power) before any
// clash. B is played → rocked for 1, then clashes: B (6, dmg 1) takes
// 5 → dead at 6; rhino takes 6 → dead at 5. Both out: draw.
rocks := eventsOfType(res, "rock")
if len(rocks) != 2 || rocks[0].Seat != 0 || !rocks[0].TargetDied {
t.Fatalf("rhino should rock each played enemy pet: %+v", rocks)
}
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Crocodile's set-aside fires when the enemy plays their last pet.
func TestCrocodileLastPetRocks(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Crocodile"), g.pet("Anchor", 9)}, // croc: 4
[]Card{g.pet("Tank", 4), g.pet("Last", 4)},
)
// Croc and Tank (4v4) kill each other; croc sets aside. Anchor and
// Last are played — Last is the enemy's last pet, so croc volleys 3
// rocks (3 damage): Last at 3 damage. Clash: Last dies (3+9), Anchor
// takes 4. Seat 0 wins.
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Target != 1 || rocks[0].Roll != 3 {
t.Fatalf("crocodile should volley the enemy's last pet: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Scorpion KOs whatever its clash attack hurts — but a fully prevented
// attack (Garlic on a 1-power hit) KOs nothing, and its rocks never KO.
func TestScorpionKnockout(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Scorpion")}, // 1 power
[]Card{g.pet("Giant", 9)},
)
clash := eventsOfType(res, "clash")[0]
if !clash.Died[1] {
t.Fatalf("scorpion's hit should KO the giant: %+v", clash)
}
if !clash.Died[0] {
t.Fatal("the giant's 9 attack still kills the scorpion")
}
if res.WinnerSeat != -1 {
t.Fatalf("expected mutual destruction draw, got %d", res.WinnerSeat)
}
}
func TestGarlicBlocksScorpionKO(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Scorpion")},
[]Card{g.realFood(t, "Garlic"), g.pet("Guard", 9)},
)
// Scorpion's 1 attack is fully prevented by garlic: no hurt, no KO.
clash := eventsOfType(res, "clash")[0]
if clash.Died[1] {
t.Fatalf("garlic should negate the scorpion's KO: %+v", clash)
}
if res.WinnerSeat != 1 {
t.Fatalf("guard should win, got %d", res.WinnerSeat)
}
}
// Seal only pays out if it carries a perk.
func TestSealNeedsPerk(t *testing.T) {
g, _, _ := testGame(t)
// Without a perk: nothing.
res := forceBattle(t, g,
[]Card{g.realPet(t, "Seal")},
[]Card{g.pet("Enemy", 9)},
)
if len(eventsOfType(res, "summon")) != 0 {
t.Fatal("seal without a perk should summon nothing")
}
// With honey attached: 3 apples onto the deck.
g2, _, _ := testGame(t)
res2 := forceBattle(t, g2,
[]Card{g2.newHoney(t), g2.realPet(t, "Seal"), g2.pet("Heir", 3)},
[]Card{g2.pet("Enemy", 9)},
)
apples := 0
for _, s := range eventsOfType(res2, "summon") {
if s.Card.Food == FoodApple && s.Seat == 0 {
apples++
}
}
if apples != 3 {
t.Fatalf("perked seal should push 3 apples onto its deck, got %d", apples)
}
}
// Shark eats an apple per fainted friendly pet.
func TestSharkEatsPerFaintedPet(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("A", 1), g.pet("B", 1), g.realPet(t, "Shark")}, // shark: 1
[]Card{g.pet("Wall", 3)},
)
// A and B faint chipping Wall to 2 damage (1 health). Shark plays with
// 2 fainted friends → eats 2 → 3 power. Clash: Wall dies (3 more
// damage), shark survives 3 damage? 3 damage vs 3 power = fainted too.
eats := eventsOfType(res, "eat")
if len(eats) != 1 || eats[0].Bonus != 2 {
t.Fatalf("shark should eat 2 apples: %+v", eats)
}
if res.WinnerSeat != -1 {
t.Fatalf("expected draw (both die in final clash), got %d", res.WinnerSeat)
}
}
// Turkey's aura buffs later friendly bees.
func TestTurkeyBuffsBees(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Turkey"), g.tier1(t, "Cricket")}, // turkey 4, cricket 1
[]Card{g.pet("Wall", 6)},
)
// Turkey dies (Wall at 4 damage, 2 health), aura set. Cricket dies
// (Wall at 5 damage, 1 health), summons its bee. The bee plays at
// 1+1=2 power: kills Wall and survives with 6 damage? No — bee takes
// 6, dies; Wall takes 2, dies at 7 ≥ 6. Draw... but the bee had 2
// power so Wall dies; bee dies too. Draw.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
// Verify the aura actually applied: final clash damage on Wall is
// 5+2=7 (a 1-power bee would leave it at 6 exactly = still dead...);
// check damage total instead.
clashes := eventsOfType(res, "clash")
final := clashes[len(clashes)-1]
if final.Damage[1] != 7 {
t.Fatalf("buffed bee should hit for 2 (wall at 7 damage), got %d", final.Damage[1])
}
}
// Chili mills the enemy deck down to the next non-Bee pet.
func TestChiliMillsEnemyDeck(t *testing.T) {
g, _, _ := testGame(t)
bee := g.newBee()
res := forceBattle(t, g,
[]Card{g.realFood(t, "Chili"), g.pet("Holder", 3)},
[]Card{g.pet("Front", 3), g.newApple(), bee, g.pet("Real", 3)},
)
// Holder (with chili) and Front play. Chili mills: apple, bee are
// discarded; Real stays on top. Clash: Holder and Front trade (3v3,
// both die). Real vs nothing — seat 1 wins.
mills := eventsOfType(res, "mill")
if len(mills) != 2 || mills[0].Seat != 1 {
t.Fatalf("chili should mill exactly the apple and bee: %+v", mills)
}
if mills[0].Card.Food != FoodApple || !isBee(*mills[1].Card) {
t.Fatalf("milled cards should be the apple then the bee: %+v", mills)
}
if res.WinnerSeat != 1 {
t.Fatalf("seat 1's Real pet should carry the day, got %d", res.WinnerSeat)
}
}
// --- Tier 6 ---
// Gorilla is hurt only every other hit.
func TestGorillaAlternatingShield(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Gorilla")}, // 6 power
[]Card{g.pet("A", 2), g.pet("B", 2), g.pet("C", 2)},
)
// Hit 1: takes 2 (damage 2), gains shield. Hit 2: blocked. Hit 3:
// takes 2 (damage 4), gains shield. Gorilla kills all three.
shields := eventsOfType(res, "shield")
if len(shields) != 1 || shields[0].Seat != 0 {
t.Fatalf("gorilla should block exactly the second hit: %+v", shields)
}
clashes := eventsOfType(res, "clash")
if len(clashes) != 3 || clashes[2].Damage[0] != 4 {
t.Fatalf("gorilla should end at 4 damage after 3 clashes: %+v", clashes)
}
if res.WinnerSeat != 0 {
t.Fatalf("gorilla should win, got %d", res.WinnerSeat)
}
}
// Melon blocks the first hit against its pet.
func TestMelonBlocksFirstHit(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realFood(t, "Melon"), g.pet("Holder", 3)},
[]Card{g.pet("Big", 5)},
)
// Hit 1 blocked; holder hits Big for 3. Hit 2: holder takes 5, dies;
// Big takes 3 more → 6 ≥ 5, dies. Draw.
shields := eventsOfType(res, "shield")
if len(shields) != 1 || shields[0].Seat != 0 {
t.Fatalf("melon should block the first hit: %+v", shields)
}
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Leopard throws rocks equal to its power, apples included.
func TestLeopardRocksEqualPower(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.newApple(), g.newApple(), g.newApple(), g.realPet(t, "Leopard")}, // 4+3=7
[]Card{g.pet("Fortress", 7)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 7 || !rocks[0].TargetDied {
t.Fatalf("leopard should throw 7 rocks (7 damage) and level the fortress: %+v", rocks)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Mammoth's aura buffs all later friendly pets.
func TestMammothBuffsPets(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Mammoth"), g.pet("Heir", 3)}, // mammoth: 4
[]Card{g.pet("Wall", 8)},
)
// Mammoth dies (Wall at 4 damage). Heir plays at 3+1=4: dies to the 8,
// but Wall ends at 8 damage ≥ 8 → dead too. Draw proves the +1.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw (aura makes exactly lethal), got %d", res.WinnerSeat)
}
}
// Cat doubles the apples in hand after adding two.
func TestCatDoublesApples(t *testing.T) {
g, _, _ := testGame(t)
p := current(g)
p.Deck = append(p.Deck, g.newApple()) // 1 apple already in hand
g.ShopRow[0] = g.realPet(t, "Cat")
if err := g.Buy(p.ID, 0); err != nil {
t.Fatal(err)
}
// 1 existing + 2 added = 3, doubled = 6.
if countApplesIn(p.Deck) != 6 {
t.Fatalf("cat should leave 6 apples ((1+2)×2), got %d", countApplesIn(p.Deck))
}
}
// Snake volleys on EVERY friendly pet play.
func TestSnakeRecurringRocks(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
res := forceBattle(t, g,
[]Card{g.realPet(t, "Snake"), g.pet("A", 2), g.pet("B", 2)}, // snake: 2
[]Card{g.pet("Wall", 9)},
)
// Snake dies. A plays → 2 rocks (2 damage) → Wall 2+2... sequence:
// snake clash: wall 2 damage. A plays → rocks 2 → wall 4. Clash: A
// dies, wall 6. B plays → rocks 2 → wall 8. Clash: B dies, wall 10 ≥ 9
// → dead. Both out → draw, with TWO rock volleys proving recurrence.
rocks := eventsOfType(res, "rock")
if len(rocks) != 2 {
t.Fatalf("snake should volley on each friendly play: %+v", rocks)
}
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}
// Wolverine steals up to 3 apples, which can faint a wounded victim.
func TestWolverineStealsApples(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5
[]Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.pet("Hoard", 2)}, // 2+4=6
)
// Chip (4) dies to Hoard (6); Hoard carries 4 damage (2 health).
// Wolverine plays, steals 3 apples: Hoard drops to 3 power with 4
// damage → faints. Wolverine ends at 5+3=8 power.
steals := eventsOfType(res, "steal")
if len(steals) != 1 || steals[0].Count != 3 || !steals[0].TargetDied {
t.Fatalf("wolverine should steal 3 apples and kill the hoard: %+v", steals)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
}
}
// Fly summons three bees on faint.
func TestFlySummonsThreeBees(t *testing.T) {
g, _, _ := testGame(t)
res := forceBattle(t, g,
[]Card{g.realPet(t, "Fly")}, // 4 power
[]Card{g.pet("Wall", 7)},
)
summons := eventsOfType(res, "summon")
if len(summons) != 3 {
t.Fatalf("fly should summon 3 bees: %+v", summons)
}
// Wall takes 4+1+1+1 = 7 → dies with the last bee. Draw.
if res.WinnerSeat != -1 {
t.Fatalf("expected draw, got %d", res.WinnerSeat)
}
}