1314 lines
44 KiB
Go
1314 lines
44 KiB
Go
package game
|
||
|
||
import (
|
||
"fmt"
|
||
)
|
||
|
||
// BattleUnit is a pet in play with its attached foods applied. Power
|
||
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||
type BattleUnit struct {
|
||
Card Card `json:"card"`
|
||
Foods []Card `json:"foods,omitempty"`
|
||
Bonus int `json:"bonus"` // total power added by foods, eating, auras
|
||
Damage int `json:"damage"` // damage markers accumulated this battle
|
||
// Shields are this pet's own full-hit blocks, newest last. Each carries
|
||
// its source so the log can name it (Gorilla's innate block, or a Melon
|
||
// perk) and the client can drop the spent card.
|
||
Shields []shieldCharge
|
||
// afterAttackUsed gates once-per-battle After-Attack effects (Bulldog).
|
||
afterAttackUsed bool
|
||
// hitPrevent are this pet's own one-shot partial damage preventions
|
||
// (Potato perk: two charges of 2). Consumed before any side-level charge.
|
||
hitPrevent []int
|
||
}
|
||
|
||
// shieldCharge is one full-hit block a pet carries. source is the granting
|
||
// item's display name (e.g. "Melon"); an empty source is the pet's own innate
|
||
// shield (Gorilla). card, when set, is the food to remove from the play area
|
||
// once the charge is spent.
|
||
type shieldCharge struct {
|
||
source string
|
||
card *Card
|
||
}
|
||
|
||
// shieldBlock describes a hit that a shield just absorbed, for the caller to
|
||
// narrate and clean up: source names the shield (empty = innate), and release
|
||
// is the field card to remove — a Melon food or a set-aside Turtle — or nil.
|
||
type shieldBlock struct {
|
||
source string
|
||
release *Card
|
||
}
|
||
|
||
// preventInfo describes a partial damage prevention a hit just consumed (Cone
|
||
// Snail): amount is the damage shaved off, release is the set-aside card to
|
||
// remove from the board. The caller narrates it after its own hit event so the
|
||
// replay order stays correct.
|
||
type preventInfo struct {
|
||
amount int
|
||
release *Card
|
||
}
|
||
|
||
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.
|
||
// "setaside": Card (a fainted pet) enters Seat's set-aside zone with a
|
||
// pending effect (Blowfish, Badger, Snake, Crocodile, Turtle,
|
||
// Turkey, Mammoth).
|
||
// "release": a set-aside Card left Seat's zone, its effect consumed
|
||
// (matched by Card.ID).
|
||
Type string `json:"type"`
|
||
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
|
||
// player) and dropping it makes the client read sides[undefined].
|
||
Seat int `json:"seat"`
|
||
Target int `json:"target"`
|
||
Card *Card `json:"card,omitempty"`
|
||
Count int `json:"count,omitempty"`
|
||
// clash: per-seat damage totals / deaths after the exchange.
|
||
Damage []int `json:"damage,omitempty"`
|
||
Died []bool `json:"died,omitempty"`
|
||
// rock: individual die faces rolled (each 0, 1, or 2), for the dice
|
||
// animation; Roll is their sum.
|
||
Dice []int `json:"dice,omitempty"`
|
||
// rock: dice total rolled; rock/strip/steal: target pet's fate.
|
||
Roll int `json:"roll"`
|
||
DamageAfter int `json:"damageAfter"`
|
||
TargetDied bool `json:"targetDied,omitempty"`
|
||
// eat: the pet's power bonus after eating.
|
||
Bonus int `json:"bonus"`
|
||
// Text is a human-readable description of this step for the event log,
|
||
// e.g. "Ant's faint effect summons a Bee." Empty for steps not worth a
|
||
// line (they still animate).
|
||
Text string `json:"text,omitempty"`
|
||
}
|
||
|
||
// BattleResult is the full, public record of one round's battle.
|
||
type BattleResult struct {
|
||
Round int `json:"round"`
|
||
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||
// Lineups is each seat's arranged deck at battle start (top of deck
|
||
// first). Public so players can review the whole matchup — including the
|
||
// opponent's cards — during and after the fight.
|
||
Lineups [][]Card `json:"lineups,omitempty"`
|
||
Events []BattleEvent `json:"events"`
|
||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||
Trophies int `json:"trophies"` // awarded to the winner
|
||
}
|
||
|
||
// setAsideRocks is a fainted pet's pending rock payout.
|
||
type setAsideRocks struct {
|
||
dice int
|
||
everyone bool // Badger: hits every active pet, the owner's own included
|
||
src Card // the fainted pet, for the set-aside display
|
||
}
|
||
|
||
// lastPetVolley is Crocodile's set-aside: rocks that fire when the enemy plays
|
||
// the last pet in their deck.
|
||
type lastPetVolley struct {
|
||
dice int
|
||
src Card // the fainted pet, for the set-aside display
|
||
}
|
||
|
||
// feedAside is Giant Isopod's set-aside: each time the owner plays a pet, spend
|
||
// one Trumpet to feed that pet `apples` apples.
|
||
type feedAside struct {
|
||
apples int
|
||
src Card // the fainted pet, for the set-aside display
|
||
}
|
||
|
||
// 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 []setAsideRocks // Snake: on every own pet play
|
||
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
|
||
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
||
|
||
// --- Golden pack ---
|
||
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
|
||
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
|
||
grSummoned bool // Golden Retriever already summoned this battle
|
||
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
|
||
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
|
||
beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played
|
||
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
|
||
petsPlayed int // pets fielded so far (Komodo's "first pet")
|
||
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// canField reports whether the side has, or can still produce, a pet to fight:
|
||
// one in play, a pet left in the stack, or a Golden Retriever waiting to be
|
||
// summoned. Used to decide the battle is over only after play effects (and any
|
||
// parting shots) have resolved.
|
||
func (s *battleSide) canField() bool {
|
||
if s.unit != nil || s.hasPetInStack() {
|
||
return true
|
||
}
|
||
return len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned
|
||
}
|
||
|
||
// 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)
|
||
release *Card // set-aside card to release once this play resolves (Blowfish/Badger/Croc)
|
||
source *Card // set-aside pet credited as the effect's source (Blowfish/Badger/Croc/Snake)
|
||
}
|
||
|
||
// effectCount resolves an effect's final count: base × Per statistic,
|
||
// limited by Cap. enemy is the opposing side (for enemy-relative multipliers);
|
||
// it may be nil when the effect has no such Per.
|
||
func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) 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()
|
||
case PerUniqueFaintedHats:
|
||
n *= len(s.faintedHats)
|
||
case PerEnemyFaintedPets:
|
||
if enemy != nil {
|
||
n *= enemy.petsFainted
|
||
} else {
|
||
n = 0
|
||
}
|
||
}
|
||
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.
|
||
//
|
||
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and
|
||
// publishes the completed result.
|
||
func (g *Game) resolveBattle() {
|
||
res := g.runBattle()
|
||
g.Battle = res
|
||
g.finalizeBattle(res)
|
||
}
|
||
|
||
// finalizeBattle applies the persistent effects of a completed battle: trophies,
|
||
// the priority token hand-off, the result log line, and clearing the per-round
|
||
// apples-in-play bank. Kept separate from runBattle, which mutates no persistent
|
||
// player state.
|
||
func (g *Game) finalizeBattle(res *BattleResult) {
|
||
n := len(g.Players)
|
||
winner := res.WinnerSeat
|
||
if winner >= 0 {
|
||
g.Players[winner].Trophies += res.Trophies
|
||
// Priority token: the winner hands it to the other player; a loser who
|
||
// held it keeps it; a draw leaves it put. (Two-player rule.)
|
||
if winner == g.PrioritySeat {
|
||
g.PrioritySeat = (winner + 1) % n
|
||
}
|
||
}
|
||
if winner < 0 {
|
||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||
} else {
|
||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
|
||
}
|
||
for _, p := range g.Players {
|
||
p.PendingApplesInPlay = 0
|
||
p.PendingTrumpets = 0
|
||
}
|
||
}
|
||
|
||
// runBattle plays the simulation to completion, returning the result. It
|
||
// mutates no persistent player state — that is finalizeBattle's job.
|
||
func (g *Game) runBattle() *BattleResult {
|
||
n := len(g.Players)
|
||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
|
||
sides := make([]*battleSide, n)
|
||
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
||
// pname is the owning player's display name for a seat, for log text.
|
||
pname := func(seat int) string { return g.Players[seat].Name }
|
||
|
||
for _, p := range g.Players {
|
||
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||
sides[p.Seat] = s
|
||
res.StackSizes[p.Seat] = len(p.Deck)
|
||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||
}
|
||
// enemyOf returns the opposing side (two-player; generalizes later).
|
||
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
|
||
// seatOrder resolves the priority-token holder first, then everyone else.
|
||
// Reveals, queued play effects, and cross-side triggers all follow it, so
|
||
// when two pets would act simultaneously (e.g. both throwing rocks) the
|
||
// holder acts first — its rocks can faint the enemy pet before that pet's
|
||
// own queued rocks resolve.
|
||
seatOrder := make([]int, 0, n)
|
||
seatOrder = append(seatOrder, g.PrioritySeat)
|
||
for seat := range sides {
|
||
if seat != g.PrioritySeat {
|
||
seatOrder = append(seatOrder, seat)
|
||
}
|
||
}
|
||
// startApple seeds one in-play apple onto a seat's first pet.
|
||
startApple := func(seat int) {
|
||
apple := g.newApple()
|
||
sides[seat].pending = append(sides[seat].pending, apple)
|
||
emit(BattleEvent{Type: "prep", Seat: seat, Card: &apple,
|
||
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(seat))})
|
||
}
|
||
// Battle-prep effects that start apples in play (Monkey): they attach
|
||
// to the owner's first pet.
|
||
for _, p := range g.Players {
|
||
for _, c := range p.Deck {
|
||
for _, e := range c.Effects {
|
||
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||
for range e.count() {
|
||
startApple(p.Seat)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||
// round (read-only here; finalizeBattle clears it once the battle ends,
|
||
// so re-runs bank the same amount).
|
||
for range p.PendingApplesInPlay {
|
||
startApple(p.Seat)
|
||
}
|
||
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||
if p.PendingTrumpets > 0 {
|
||
sides[p.Seat].trumpets += p.PendingTrumpets
|
||
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
|
||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||
}
|
||
}
|
||
|
||
summon := func(seat int, c Card, cause string) {
|
||
s := sides[seat]
|
||
s.stack = append([]Card{c}, s.stack...)
|
||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
|
||
}
|
||
// summonBottom puts a card on the BOTTOM of a seat's stack (Bear).
|
||
summonBottom := func(seat int, c Card, cause string) {
|
||
s := sides[seat]
|
||
s.stack = append(s.stack, c)
|
||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s puts %s %s on the bottom of %s's deck.", cause, article(c.Name), c.Name, pname(seat))})
|
||
}
|
||
mintFor := func(kind string) Card {
|
||
if kind == "bee" {
|
||
return g.newBee()
|
||
}
|
||
return g.newApple()
|
||
}
|
||
// gainTrumpets adds trumpets to a side and narrates it.
|
||
gainTrumpets := func(seat, n int, cause string) {
|
||
if n <= 0 {
|
||
return
|
||
}
|
||
sides[seat].trumpets += n
|
||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
|
||
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))})
|
||
}
|
||
// spend pays an effect's Trumpet cost from a side's pool, narrating the
|
||
// spend. Returns false (without paying) when the side can't afford it, so
|
||
// the caller skips the effect. A zero-cost effect always "pays".
|
||
spend := func(seat int, e Effect, cause string) bool {
|
||
if e.CostTrumpet <= 0 {
|
||
return true
|
||
}
|
||
if sides[seat].trumpets < e.CostTrumpet {
|
||
return false
|
||
}
|
||
sides[seat].trumpets -= e.CostTrumpet
|
||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
|
||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
|
||
return true
|
||
}
|
||
|
||
// 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 the damage dealt and,
|
||
// when a shield absorbed the hit, a shieldBlock describing it (nil
|
||
// otherwise). A set-aside Turtle shield is spent before the pet's own
|
||
// shields (Melon/Gorilla) so the borrowed card clears the board first.
|
||
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) {
|
||
u := sides[seat].unit
|
||
if u == nil || amount <= 0 {
|
||
return 0, nil, nil
|
||
}
|
||
if sides[seat].shields > 0 {
|
||
sides[seat].shields--
|
||
var card *Card
|
||
source := ""
|
||
if n := len(sides[seat].shieldCards); n > 0 {
|
||
c := sides[seat].shieldCards[n-1]
|
||
sides[seat].shieldCards = sides[seat].shieldCards[:n-1]
|
||
card = &c
|
||
source = c.Name
|
||
}
|
||
return 0, &shieldBlock{source: source, release: card}, nil
|
||
}
|
||
if n := len(u.Shields); n > 0 {
|
||
sc := u.Shields[n-1]
|
||
u.Shields = u.Shields[:n-1]
|
||
return 0, &shieldBlock{source: sc.source, release: sc.card}, nil
|
||
}
|
||
reduce := u.prevention()
|
||
// One partial-prevention charge per hit (whether or not it fully absorbs
|
||
// the blow): the pet's own first (Potato), else a side-level set-aside
|
||
// (Cone Snail).
|
||
if len(u.hitPrevent) > 0 {
|
||
amt := u.hitPrevent[0]
|
||
u.hitPrevent = u.hitPrevent[1:]
|
||
reduce += amt
|
||
prevent = &preventInfo{amount: amt}
|
||
} else if len(sides[seat].hitPrevent) > 0 {
|
||
amt := sides[seat].hitPrevent[0]
|
||
sides[seat].hitPrevent = sides[seat].hitPrevent[1:]
|
||
reduce += amt
|
||
prevent = &preventInfo{amount: amt}
|
||
if len(sides[seat].preventCards) > 0 {
|
||
c := sides[seat].preventCards[0]
|
||
sides[seat].preventCards = sides[seat].preventCards[1:]
|
||
prevent.release = &c
|
||
}
|
||
}
|
||
dealt = max(0, amount-reduce)
|
||
u.Damage += dealt
|
||
return dealt, nil, prevent
|
||
}
|
||
|
||
// emitShield narrates a blocked hit — naming the source rather than a bare
|
||
// "shield" — and removes the spent card (Melon food or set-aside Turtle)
|
||
// from the play area.
|
||
emitShield := func(seat int, petName string, block *shieldBlock) {
|
||
txt := fmt.Sprintf("%s blocks the hit.", petName)
|
||
if block.source != "" {
|
||
txt = fmt.Sprintf("%s blocks the hit with a %s.", petName, block.source)
|
||
}
|
||
emit(BattleEvent{Type: "shield", Seat: seat, Text: txt})
|
||
if block.release != nil {
|
||
emit(BattleEvent{Type: "release", Seat: seat, Card: block.release})
|
||
}
|
||
}
|
||
|
||
// emitPrevent narrates a Cone Snail partial prevention after the hit that
|
||
// consumed it, and drops the spent set-aside card.
|
||
emitPrevent := func(seat int, petName string, prev *preventInfo) {
|
||
u := sides[seat].unit
|
||
after := 0
|
||
if u != nil {
|
||
after = u.Damage
|
||
}
|
||
emit(BattleEvent{Type: "prevent", Seat: seat, Count: prev.amount, DamageAfter: after,
|
||
Text: fmt.Sprintf("A set-aside Cone Snail shields %s, preventing %d damage.", petName, prev.amount)})
|
||
if prev.release != nil {
|
||
emit(BattleEvent{Type: "release", Seat: seat, Card: prev.release})
|
||
}
|
||
}
|
||
|
||
// 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++
|
||
}
|
||
// Track distinct suits among friendly fainted pets (Honduran White
|
||
// Bat). Bees and the Golden Retriever have no suit.
|
||
if !isBee(u.Card) && u.Card.Suit != "" {
|
||
s.faintedHats[u.Card.Suit] = true
|
||
}
|
||
// setAside marks the fainted pet as kept beside the arena with a
|
||
// pending effect, so the client can show its card until it resolves.
|
||
setAside := func() {
|
||
c := u.Card
|
||
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
||
}
|
||
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||
continue
|
||
}
|
||
if !spend(seat, e, u.Card.Name) {
|
||
continue
|
||
}
|
||
switch e.Action {
|
||
case ActionSummonTop:
|
||
target := seat
|
||
if e.Target == "enemy" {
|
||
target = (seat + 1) % n
|
||
}
|
||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||
summon(target, mintFor(e.Card), cause)
|
||
}
|
||
case ActionSummonBottom:
|
||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||
if e.Target == "all" {
|
||
for other := range sides {
|
||
summonBottom(other, mintFor(e.Card), cause)
|
||
}
|
||
} else {
|
||
summonBottom(seat, mintFor(e.Card), cause)
|
||
}
|
||
}
|
||
case ActionGainTrumpet:
|
||
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||
case ActionDrainTrumpet:
|
||
es := enemyOf(seat)
|
||
lost := min(e.count(), es.trumpets)
|
||
if lost > 0 {
|
||
es.trumpets -= lost
|
||
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
|
||
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
|
||
}
|
||
case ActionPreventNextHit:
|
||
s.hitPrevent = append(s.hitPrevent, e.count())
|
||
s.preventCards = append(s.preventCards, u.Card)
|
||
setAside()
|
||
case ActionRecycleApples:
|
||
recycled := 0
|
||
for _, f := range u.Foods {
|
||
if f.Food == FoodApple && recycled < e.count() {
|
||
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
|
||
recycled++
|
||
}
|
||
}
|
||
case ActionRecyclePerkApples:
|
||
// Macaque: recycle up to Count apples, then the active perk on
|
||
// top (so the perk reveals first and re-attaches to the next pet).
|
||
recycled := 0
|
||
for _, f := range u.Foods {
|
||
if f.Food == FoodApple && recycled < e.count() {
|
||
summon(seat, f, cause)
|
||
recycled++
|
||
}
|
||
}
|
||
if perk := u.activePerk(); perk != nil {
|
||
summon(seat, *perk, cause)
|
||
}
|
||
case ActionBeeRocks:
|
||
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
|
||
setAside()
|
||
case ActionFeedOnPlay:
|
||
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
|
||
setAside()
|
||
case ActionGuardRetriever:
|
||
s.retrieverGuards = append(s.retrieverGuards, e.count())
|
||
setAside()
|
||
case ActionDelayedRocks:
|
||
s.oneShotRocks = append(s.oneShotRocks,
|
||
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||
setAside()
|
||
case ActionRecurringRocks:
|
||
s.recurringRocks = append(s.recurringRocks,
|
||
setAsideRocks{dice: e.count(), src: u.Card})
|
||
setAside()
|
||
case ActionEnemyLastPetRocks:
|
||
s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card})
|
||
setAside()
|
||
case ActionShieldNext:
|
||
s.shields += e.count()
|
||
s.shieldCards = append(s.shieldCards, u.Card)
|
||
setAside()
|
||
case ActionBeeAura:
|
||
s.beeBonus += e.count()
|
||
setAside()
|
||
case ActionPetAura:
|
||
s.petBonus += e.count()
|
||
setAside()
|
||
}
|
||
}
|
||
// 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, sides[seat]), os.unit.Damage)
|
||
if healed > 0 {
|
||
os.unit.Damage -= healed
|
||
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
|
||
Text: fmt.Sprintf("%s heals %d after an enemy faints.", os.unit.Card.Name, healed)})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// hurt fires Hurt effects on a pet that took damage. It fires even when the
|
||
// hit was fatal — the Lizard drops its Bee even when killed in one shot —
|
||
// except for self-buffs marked SurviveOnly (Peacock, Gorilla), which need
|
||
// the pet to live on.
|
||
hurt := func(seat int, u *BattleUnit) {
|
||
alive := u.Alive()
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||
continue
|
||
}
|
||
if !alive && e.SurviveOnly {
|
||
continue
|
||
}
|
||
if !spend(seat, e, u.Card.Name) {
|
||
continue
|
||
}
|
||
switch e.Action {
|
||
case ActionEatApple:
|
||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||
u.Foods = append(u.Foods, g.newApple())
|
||
u.Bonus++
|
||
}
|
||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
|
||
case ActionSummonTop:
|
||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||
}
|
||
case ActionGainTrumpet:
|
||
gainTrumpets(seat, effectCount(e, sides[seat], u, enemyOf(seat)), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||
case ActionShieldSelf:
|
||
// Gorilla's own hurt-triggered block: innate, no card to drop.
|
||
for range e.count() {
|
||
u.Shields = append(u.Shields, shieldCharge{})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// afterAttack fires After-Attack effects on a pet that survived a clash it
|
||
// fought in (Bulldog eats an apple). Effects here are once per battle.
|
||
afterAttack := func(seat int, u *BattleUnit) {
|
||
if !u.Alive() || u.afterAttackUsed {
|
||
return
|
||
}
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
|
||
continue
|
||
}
|
||
if !spend(seat, e, u.Card.Name) {
|
||
continue
|
||
}
|
||
switch e.Action {
|
||
case ActionEatApple:
|
||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||
u.Foods = append(u.Foods, g.newApple())
|
||
u.Bonus++
|
||
}
|
||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
|
||
}
|
||
u.afterAttackUsed = true
|
||
}
|
||
}
|
||
|
||
// 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, source *Card) (killed bool) {
|
||
tu := sides[target].unit
|
||
if tu == nil || dice <= 0 {
|
||
// No target, or a Per-scaled volley that came out to zero (Royal
|
||
// Flycatcher / Grizzly with no fainted pets): nothing to animate.
|
||
return false
|
||
}
|
||
roll := 0
|
||
faces := make([]int, dice)
|
||
for i := range dice {
|
||
faces[i] = g.rollRockDie()
|
||
roll += faces[i]
|
||
}
|
||
dealt, block, prev := hitUnit(target, roll)
|
||
died := !tu.Alive()
|
||
// A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a
|
||
// different pet has been played, so credit `source` explicitly; only
|
||
// fall back to the pet in play for direct throwers (Hedgehog/Rhino).
|
||
var rockTxt string
|
||
if source != nil {
|
||
switch {
|
||
case roll == 0:
|
||
rockTxt = fmt.Sprintf("%s's set-aside rocks miss %s.", source.Name, tu.Card.Name)
|
||
case died:
|
||
rockTxt = fmt.Sprintf("%s's set-aside rocks pelt %s for %d — it faints.", source.Name, tu.Card.Name, roll)
|
||
default:
|
||
rockTxt = fmt.Sprintf("%s's set-aside rocks pelt %s for %d.", source.Name, tu.Card.Name, roll)
|
||
}
|
||
} else {
|
||
thrower := pname(from)
|
||
if su := sides[from].unit; su != nil {
|
||
thrower = su.Card.Name
|
||
}
|
||
switch {
|
||
case roll == 0:
|
||
rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name)
|
||
case died:
|
||
rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll)
|
||
default:
|
||
rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll)
|
||
}
|
||
}
|
||
emit(BattleEvent{
|
||
Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces,
|
||
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
|
||
})
|
||
if block != nil {
|
||
emitShield(target, tu.Card.Name, block)
|
||
}
|
||
if prev != nil {
|
||
emitPrevent(target, tu.Card.Name, prev)
|
||
}
|
||
if died {
|
||
// Hurt still fires on a fatal hit (Lizard's Bee), before the faint.
|
||
if dealt > 0 {
|
||
hurt(target, tu)
|
||
}
|
||
faint(target, tu)
|
||
sides[target].unit = nil
|
||
return true
|
||
}
|
||
if dealt > 0 {
|
||
hurt(target, tu)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// nextTarget finds the seat whose pet a standard enemy-directed play
|
||
// effect hits.
|
||
nextTarget := func(from int) int {
|
||
for off := 1; off < n; off++ {
|
||
cand := (from + off) % n
|
||
if sides[cand].unit != nil {
|
||
return cand
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// The exchange loop terminates: every clash kills at least one pet or
|
||
// is a detected stalemate, and summons/auras are finite. The guard is
|
||
// just insurance as effects get richer.
|
||
//
|
||
// clashStreak counts how many times the current pair has clashed without
|
||
// either fainting. Some effects (e.g. eat-an-apple-when-hurt) can make two
|
||
// pets un-killable, looping forever; if they trade blows deadlockLimit
|
||
// times in a row, both drop so the battle can proceed. It resets whenever
|
||
// the matchup changes (a fresh pet enters).
|
||
const deadlockLimit = 9
|
||
clashStreak := 0
|
||
for range 10_000 {
|
||
// Reveal until every side has a pet in play or runs out. Play
|
||
// effects queue up and resolve after all reveals (simultaneous).
|
||
var plays []queuedPlay
|
||
newlyPlayed := make([]bool, n)
|
||
for _, seat := range seatOrder {
|
||
s := sides[seat]
|
||
for s.unit == nil && len(s.stack) > 0 {
|
||
c := s.stack[0]
|
||
s.stack = s.stack[1:]
|
||
if c.IsFood() {
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s's %s is set aside for the next pet.", pname(seat), c.Name)})
|
||
s.pending = append(s.pending, c)
|
||
continue
|
||
}
|
||
u := &BattleUnit{Card: c, Foods: s.pending}
|
||
u.Bonus += appleCount(s.pending)
|
||
u.Bonus += s.petBonus
|
||
if isBee(c) {
|
||
u.Bonus += s.beeBonus
|
||
}
|
||
// Pets carry their starting bonus (foods + auras) in the
|
||
// reveal event so clients can display it directly.
|
||
revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name)
|
||
if u.Bonus > 0 {
|
||
revealTxt = fmt.Sprintf("%s's %s enters the fray (+%d).", pname(seat), c.Name, u.Bonus)
|
||
}
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
|
||
s.pending = nil
|
||
s.unit = u
|
||
s.petsPlayed++
|
||
newlyPlayed[seat] = true
|
||
// Set-aside payouts fire before the new pet's own play
|
||
// effects.
|
||
for _, r := range s.oneShotRocks {
|
||
src := r.src
|
||
plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone,
|
||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src, source: &src})
|
||
}
|
||
s.oneShotRocks = nil
|
||
for _, r := range s.recurringRocks {
|
||
src := r.src
|
||
plays = append(plays, queuedPlay{seat: seat,
|
||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||
}
|
||
// Poison Dart Frog: rocks whenever a Bee is played.
|
||
if isBee(c) {
|
||
for _, r := range s.beePlayRocks {
|
||
src := r.src
|
||
plays = append(plays, queuedPlay{seat: seat,
|
||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||
}
|
||
}
|
||
// Giant Isopod: each set-aside spends one Trumpet (mandatory when
|
||
// affordable) to feed the just-played pet its apples.
|
||
for i := range s.feedOnPlay {
|
||
if s.trumpets <= 0 {
|
||
break
|
||
}
|
||
s.trumpets--
|
||
src := s.feedOnPlay[i].src
|
||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -1,
|
||
Text: fmt.Sprintf("%s spends 1 Trumpet.", src.Name)})
|
||
for range s.feedOnPlay[i].apples {
|
||
u.Foods = append(u.Foods, g.newApple())
|
||
u.Bonus++
|
||
}
|
||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)})
|
||
}
|
||
// Walk the pet's own play effects, then its active perk's, so a
|
||
// play-time shield knows its source (an innate pet block vs a
|
||
// Melon perk that should be shown and later dropped).
|
||
addPlay := func(e Effect, perk *Card) {
|
||
if e.Trigger != TriggerPlay {
|
||
return
|
||
}
|
||
// Shields apply the instant the pet enters play, ahead of
|
||
// any queued rocks (Melon; Wildebeest, which pays Trumpets).
|
||
if e.Action == ActionShieldSelf {
|
||
if !spend(seat, e, u.Card.Name) {
|
||
return
|
||
}
|
||
for range e.count() {
|
||
ch := shieldCharge{}
|
||
if perk != nil {
|
||
food := *perk
|
||
ch = shieldCharge{source: perk.Name, card: &food}
|
||
}
|
||
u.Shields = append(u.Shields, ch)
|
||
}
|
||
return
|
||
}
|
||
// Potato's partial preventions likewise arm on entry.
|
||
if e.Action == ActionPreventSelf {
|
||
for range max(e.Cap, 1) {
|
||
u.hitPrevent = append(u.hitPrevent, e.count())
|
||
}
|
||
return
|
||
}
|
||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||
}
|
||
for _, e := range u.Card.Effects {
|
||
addPlay(e, nil)
|
||
}
|
||
if perk := u.activePerk(); perk != nil {
|
||
for _, e := range perk.Effects {
|
||
addPlay(e, perk)
|
||
}
|
||
}
|
||
}
|
||
// Golden pack: a side that has run out of cards but still holds
|
||
// Trumpets fields a one-time Golden Retriever, its Power equal to
|
||
// those Trumpets. This happens before the "anyone out?" check so it
|
||
// can still clash.
|
||
if s.unit == nil && len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned {
|
||
gr := g.newGoldenRetriever(s.trumpets)
|
||
grUnit := &BattleUnit{Card: gr}
|
||
// German Shepherd: each set-aside guards the Golden Retriever's
|
||
// first hits (partial preventions).
|
||
grUnit.hitPrevent = append(grUnit.hitPrevent, s.retrieverGuards...)
|
||
s.unit = grUnit
|
||
s.grSummoned = true
|
||
s.petsPlayed++
|
||
newlyPlayed[seat] = true
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &gr, Count: s.trumpets, Bonus: 0,
|
||
Text: fmt.Sprintf("%s is out of cards — a Golden Retriever charges in with %d Trumpet%s (Power %d).",
|
||
pname(seat), s.trumpets, plural(s.trumpets), s.trumpets)})
|
||
}
|
||
}
|
||
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||
// Crocodile volleys when the enemy plays their last pet.
|
||
for _, seat := range seatOrder {
|
||
if !newlyPlayed[seat] {
|
||
continue
|
||
}
|
||
for _, other := range seatOrder {
|
||
os := sides[other]
|
||
if other == seat {
|
||
continue
|
||
}
|
||
if os.unit != nil {
|
||
for _, e := range os.unit.effects() {
|
||
if e.Trigger == TriggerEnemyPlay && e.Action == ActionThrowRock && allowed(e, os.unit) {
|
||
plays = append(plays, queuedPlay{seat: other, unit: os.unit, effect: e})
|
||
}
|
||
}
|
||
}
|
||
if !sides[seat].hasPetInStack() {
|
||
for _, v := range os.lastPetRocks {
|
||
src := v.src
|
||
plays = append(plays, queuedPlay{seat: other,
|
||
effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src, source: &src})
|
||
}
|
||
os.lastPetRocks = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// A fresh pet on either side is a new matchup, so the deadlock streak
|
||
// starts over.
|
||
for _, np := range newlyPlayed {
|
||
if np {
|
||
clashStreak = 0
|
||
break
|
||
}
|
||
}
|
||
|
||
// Resolve play effects. Any of these can faint a pet before the clash.
|
||
// This runs before the "battle over" check so a parting shot fires even
|
||
// when its own side is already out (Crocodile's last-pet volley).
|
||
anyDeath := false
|
||
for _, q := range plays {
|
||
// Effects sourced from a specific unit fizzle if it's gone — unless
|
||
// they're posthumous (the Manatee still adds its Apples after rocking
|
||
// itself to death).
|
||
if q.unit != nil && sides[q.seat].unit != q.unit && !q.effect.Posthumous {
|
||
continue
|
||
}
|
||
if q.unit != nil && !allowed(q.effect, q.unit) {
|
||
continue
|
||
}
|
||
costName := pname(q.seat)
|
||
if q.unit != nil {
|
||
costName = q.unit.Card.Name
|
||
}
|
||
if !spend(q.seat, q.effect, costName) {
|
||
continue
|
||
}
|
||
switch q.effect.Action {
|
||
case ActionThrowRock:
|
||
dice := q.effect.count()
|
||
if q.unit != nil {
|
||
dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||
}
|
||
switch {
|
||
case q.everyone:
|
||
for seat := range sides {
|
||
if throwRocks(q.seat, seat, dice, q.source) {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
case q.effect.Target == "self":
|
||
// Manatee pelts its own pet.
|
||
if throwRocks(q.seat, q.seat, dice, q.source) {
|
||
anyDeath = true
|
||
}
|
||
default:
|
||
if t := nextTarget(q.seat); t >= 0 {
|
||
if throwRocks(q.seat, t, dice, q.source) {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
}
|
||
if q.release != nil {
|
||
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||
}
|
||
case ActionGainTrumpet:
|
||
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||
case ActionDoubleTrumpets:
|
||
gain := sides[q.seat].trumpets
|
||
if q.effect.Cap > 0 && gain > q.effect.Cap {
|
||
gain = q.effect.Cap
|
||
}
|
||
gainTrumpets(q.seat, gain, costName)
|
||
case ActionStealPerk:
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
perk := tu.activePerk()
|
||
if perk == nil {
|
||
continue
|
||
}
|
||
stolen := *perk
|
||
// Drop that perk food from the enemy pet (perks add no power, so
|
||
// no bonus change) and put it on top of the thief's deck.
|
||
for i := range tu.Foods {
|
||
if tu.Foods[i].ID == stolen.ID {
|
||
tu.Foods = append(tu.Foods[:i], tu.Foods[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
summon(q.seat, stolen, fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t,
|
||
Text: fmt.Sprintf("%s snatches %s's %s.", q.unit.Card.Name, tu.Card.Name, stolen.Name)})
|
||
case ActionStripApples:
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
apples := appleCount(tu.Foods)
|
||
if apples == 0 {
|
||
continue
|
||
}
|
||
kept := tu.Foods[:0]
|
||
for _, f := range tu.Foods {
|
||
if f.Food != FoodApple {
|
||
kept = append(kept, f)
|
||
}
|
||
}
|
||
tu.Foods = kept
|
||
tu.Bonus -= apples
|
||
died := !tu.Alive()
|
||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
|
||
Text: fmt.Sprintf("%s discards %s's apples.", q.unit.Card.Name, tu.Card.Name)})
|
||
if died {
|
||
faint(t, tu)
|
||
sides[t].unit = nil
|
||
anyDeath = true
|
||
}
|
||
case ActionStripFoods:
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
tu.Bonus -= appleCount(tu.Foods)
|
||
tu.Foods = nil
|
||
died := !tu.Alive()
|
||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
|
||
Text: fmt.Sprintf("%s strips %s's apples away.", q.unit.Card.Name, tu.Card.Name)})
|
||
if died {
|
||
faint(t, tu)
|
||
sides[t].unit = nil
|
||
anyDeath = true
|
||
}
|
||
case ActionStealApples:
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
steal := min(q.effect.count(), appleCount(tu.Foods))
|
||
if steal == 0 {
|
||
continue
|
||
}
|
||
moved := 0
|
||
kept := tu.Foods[:0]
|
||
for _, f := range tu.Foods {
|
||
if f.Food == FoodApple && moved < steal {
|
||
q.unit.Foods = append(q.unit.Foods, f)
|
||
moved++
|
||
continue
|
||
}
|
||
kept = append(kept, f)
|
||
}
|
||
tu.Foods = kept
|
||
tu.Bonus -= moved
|
||
q.unit.Bonus += moved
|
||
died := !tu.Alive()
|
||
emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died,
|
||
Text: fmt.Sprintf("%s steals %d apple%s from %s.", q.unit.Card.Name, moved, plural(moved), tu.Card.Name)})
|
||
if died {
|
||
faint(t, tu)
|
||
sides[t].unit = nil
|
||
anyDeath = true
|
||
}
|
||
case ActionMillEnemy:
|
||
t := (q.seat + 1) % n
|
||
ts := sides[t]
|
||
for len(ts.stack) > 0 {
|
||
top := ts.stack[0]
|
||
if top.IsPet() && !isBee(top) {
|
||
break
|
||
}
|
||
ts.stack = ts.stack[1:]
|
||
emit(BattleEvent{Type: "mill", Seat: t, Card: &top,
|
||
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
|
||
}
|
||
case ActionSummonTop:
|
||
for range effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) {
|
||
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||
}
|
||
case ActionEatApple:
|
||
count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||
if count > 0 {
|
||
for range count {
|
||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||
q.unit.Bonus++
|
||
}
|
||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
|
||
}
|
||
case ActionShuffleApples:
|
||
// Komodo: only if it's the side's first pet, add apples to the
|
||
// remaining deck and shuffle the whole thing so the apples and the
|
||
// existing pets intermix (not just apples dropped in).
|
||
if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 {
|
||
continue
|
||
}
|
||
s := sides[q.seat]
|
||
for range q.effect.count() {
|
||
apple := g.newApple()
|
||
s.stack = append(s.stack, apple)
|
||
emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple,
|
||
Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))})
|
||
}
|
||
g.battleShuffle(s.stack)
|
||
case ActionSpendRocks:
|
||
// Nurse Shark: spend as many Trumpets as available (up to Count) to
|
||
// throw two rocks each.
|
||
s := sides[q.seat]
|
||
choice := min(q.effect.count(), s.trumpets)
|
||
if choice > 0 {
|
||
s.trumpets -= choice
|
||
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
|
||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
|
||
if t := nextTarget(q.seat); t >= 0 {
|
||
if throwRocks(q.seat, t, 2*choice, nil) {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Battle over? A side that can no longer field a pet is out (checked
|
||
// after play effects so parting shots land, using canField so a pet that
|
||
// will simply refill next reveal doesn't count as out).
|
||
anyOut := false
|
||
for _, s := range sides {
|
||
if !s.canField() {
|
||
anyOut = true
|
||
}
|
||
}
|
||
if anyOut {
|
||
break
|
||
}
|
||
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, blockA, prevA := hitUnit(0, powB)
|
||
dealtB, blockB, prevB := hitUnit(1, powA)
|
||
// Scorpion: a clash attack that hurts, KOs.
|
||
if dealtA > 0 && ub.hasKnockout() {
|
||
ua.Damage = max(ua.Damage, ua.Power())
|
||
}
|
||
if dealtB > 0 && ua.hasKnockout() {
|
||
ub.Damage = max(ub.Damage, ub.Power())
|
||
}
|
||
// Deadlock guard: if the pair keeps surviving, count the exchanges and
|
||
// force a mutual knockout once they hit the limit.
|
||
deadlocked := false
|
||
if ua.Alive() && ub.Alive() {
|
||
clashStreak++
|
||
if clashStreak >= deadlockLimit {
|
||
deadlocked = true
|
||
ua.Damage = max(ua.Damage, ua.Power())
|
||
ub.Damage = max(ub.Damage, ub.Power())
|
||
}
|
||
} else {
|
||
clashStreak = 0
|
||
}
|
||
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
|
||
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
|
||
switch da, db := !ua.Alive(), !ub.Alive(); {
|
||
case deadlocked:
|
||
clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.",
|
||
pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit)
|
||
case da && db:
|
||
clashTxt += " Both faint."
|
||
case da:
|
||
clashTxt += fmt.Sprintf(" %s faints.", ua.Card.Name)
|
||
case db:
|
||
clashTxt += fmt.Sprintf(" %s faints.", ub.Card.Name)
|
||
}
|
||
emit(BattleEvent{
|
||
Type: "clash",
|
||
Damage: []int{ua.Damage, ub.Damage},
|
||
Died: []bool{!ua.Alive(), !ub.Alive()},
|
||
Text: clashTxt,
|
||
})
|
||
if blockA != nil {
|
||
emitShield(0, ua.Card.Name, blockA)
|
||
}
|
||
if blockB != nil {
|
||
emitShield(1, ub.Card.Name, blockB)
|
||
}
|
||
if prevA != nil {
|
||
emitPrevent(0, ua.Card.Name, prevA)
|
||
}
|
||
if prevB != nil {
|
||
emitPrevent(1, ub.Card.Name, prevB)
|
||
}
|
||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
|
||
blockA == nil && blockB == nil && prevA == nil && prevB == nil {
|
||
break // stalemate: nothing can ever change
|
||
}
|
||
dealt := []int{dealtA, dealtB}
|
||
for seat, u := range []*BattleUnit{ua, ub} {
|
||
if !u.Alive() {
|
||
// Hurt still fires on a fatal clash (Lizard's Bee), before the faint.
|
||
if dealt[seat] > 0 {
|
||
hurt(seat, u)
|
||
}
|
||
faint(seat, u)
|
||
sides[seat].unit = nil
|
||
} else {
|
||
if dealt[seat] > 0 {
|
||
hurt(seat, u)
|
||
}
|
||
afterAttack(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
|
||
}
|
||
}
|
||
return res
|
||
}
|