1883 lines
66 KiB
Go
1883 lines
66 KiB
Go
package game
|
||
|
||
import (
|
||
"fmt"
|
||
"slices"
|
||
)
|
||
|
||
// 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
|
||
// Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the
|
||
// damage it deals in a clash (min 0); Exposed raises the damage it takes on
|
||
// each hit. bakuGuard, when set, discards the first Ailment it would gain.
|
||
Spooked int `json:"spooked,omitempty"`
|
||
Exposed int `json:"exposed,omitempty"`
|
||
bakuGuard bool
|
||
}
|
||
|
||
// 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() }
|
||
|
||
// hasAilment reports whether the pet carries any Ailment (Mothman's condition).
|
||
func (u *BattleUnit) hasAilment() bool { return u.Spooked > 0 || u.Exposed > 0 }
|
||
|
||
// takeAilment attaches one Ailment of the given kind, unless Baku's guard eats
|
||
// it first. It reports whether the ailment actually landed.
|
||
func (u *BattleUnit) takeAilment(kind string) bool {
|
||
if u.bakuGuard {
|
||
u.bakuGuard = false
|
||
return false
|
||
}
|
||
switch kind {
|
||
case AilmentSpooked:
|
||
u.Spooked++
|
||
case AilmentExposed:
|
||
u.Exposed++
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 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).
|
||
// "mana": Seat's Mana pool changed by Count (+gain / −spend) (Unicorn).
|
||
// "ailment": Seat's pet gained Count Ailment cards (Card names the kind)
|
||
// (Unicorn: Barghest, Basilisk, Frost Wolf, …).
|
||
// "bounce": Seat's pet sent Target's pet (Card) to the bottom of Target's
|
||
// deck (Unicorn: Loveland Frogman).
|
||
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 battle.
|
||
//
|
||
// A round runs one battle per pairing (see schedule.go), so a six-player round
|
||
// produces three of these. Everything inside a result is indexed by *side* —
|
||
// 0 or 1 within this battle — not by the player's seat at the table: Seats maps
|
||
// the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat
|
||
// is the exception, and is a real seat, because it's the one field that means
|
||
// something outside the battle.
|
||
type BattleResult struct {
|
||
Round int `json:"round"`
|
||
// Seats are the two players fighting, in first-player order: Seats[0] holds
|
||
// priority and acts first when two effects would land simultaneously.
|
||
Seats []int `json:"seats"`
|
||
StackSizes []int `json:"stackSizes"` // starting deck size per side
|
||
// Lineups is each side'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"` // a seat at the table; -1 = draw
|
||
Trophies int `json:"trophies"` // awarded to the winner
|
||
// Survivors is each side's remaining force at battle end: pets still in
|
||
// play plus any never reached in the stack. The loser is 0. It measures how
|
||
// decisive the result was — the margin the AI uses to prefer a lineup that
|
||
// fights harder, even in a battle it can't win.
|
||
Survivors []int `json:"survivors,omitempty"`
|
||
// ManaAfter (Unicorn pack) is each side's persistent Mana pool once the
|
||
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
|
||
// is apples each side banked for next round's hand (Skeleton Dog).
|
||
ManaAfter []int `json:"manaAfter,omitempty"`
|
||
NextRoundApples []int `json:"nextRoundApples,omitempty"`
|
||
}
|
||
|
||
// Side returns the battle-side index (0 or 1) for a seat at the table, or -1
|
||
// if that player wasn't in this battle. Use it to read any of the per-side
|
||
// slices above from a seat.
|
||
func (r *BattleResult) Side(seat int) int {
|
||
return slices.Index(r.Seats, seat)
|
||
}
|
||
|
||
// Has reports whether a seat fought in this battle.
|
||
func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 }
|
||
|
||
// SeatOf returns the seat holding a side of this battle, or -1.
|
||
func (r *BattleResult) SeatOf(side int) int {
|
||
if side < 0 || side >= len(r.Seats) {
|
||
return -1
|
||
}
|
||
return r.Seats[side]
|
||
}
|
||
|
||
// 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
|
||
|
||
// --- Unicorn pack ---
|
||
mana int // persistent Mana pool, seeded from the player and written back
|
||
pendingAilments []Card // ailments on top of the deck, waiting for the next pet
|
||
negators []Card // Mandrake set-asides: each negates the next enemy Faint ability
|
||
nextRoundApples int // apples banked for next round's hand (Skeleton Dog)
|
||
|
||
// --- Unicorn pack, tiers 4-6 ---
|
||
unicornGuards []Card // Unicorn: each turns the next incoming friendly ailment into 2 apples
|
||
smallPetBonus int // Rootlin: +power for friendly pets with base Power <= 2
|
||
fairyGuards []Card // Fairy: each recycles the next friendly faint to the deck bottom
|
||
ailmentBoost int // Manticore: enemy pets' Ailments count for this much more
|
||
manaFeed []feedAside // Team Spirit: each pet played, spend 1 Mana to feed apples
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// resolveBattles fights every pairing of the current round, records the event
|
||
// logs, and awards trophies.
|
||
//
|
||
// Each 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.
|
||
//
|
||
// resolveBattles is the orchestrator: it runs each (deterministic) simulation
|
||
// and publishes the completed results.
|
||
func (g *Game) resolveBattles() {
|
||
g.Battles = nil
|
||
for _, m := range g.Pairings() {
|
||
first, second := g.firstPlayer(m)
|
||
res := g.runBattle(first, second)
|
||
g.Battles = append(g.Battles, res)
|
||
g.finalizeBattle(res)
|
||
}
|
||
// Per-round bookkeeping that isn't tied to one battle: the temporary
|
||
// resources every player banked for the fight are spent now, win or lose.
|
||
for _, p := range g.Players {
|
||
p.PendingApplesInPlay = 0
|
||
p.PendingTrumpets = 0
|
||
}
|
||
}
|
||
|
||
// firstPlayer decides which half of a pairing acts first — the side that wins
|
||
// simultaneity races during the battle. Two players settle it with the
|
||
// priority token they pass between them; a bigger table flips for it, as the
|
||
// rulebook's "determine the First Player for each battle by flipping a gold
|
||
// token" asks.
|
||
func (g *Game) firstPlayer(m Matchup) (first, second int) {
|
||
if len(g.Players) == 2 {
|
||
if m[1] == g.PrioritySeat {
|
||
return m[1], m[0]
|
||
}
|
||
return m[0], m[1]
|
||
}
|
||
if randInt(2) == 1 {
|
||
return m[1], m[0]
|
||
}
|
||
return m[0], m[1]
|
||
}
|
||
|
||
// finalizeBattle applies the persistent effects of one completed battle:
|
||
// trophies, the round-win record, the priority token hand-off, and the result
|
||
// log line. Kept separate from runBattle, which mutates no persistent player
|
||
// state.
|
||
func (g *Game) finalizeBattle(res *BattleResult) {
|
||
winner := res.WinnerSeat
|
||
if winner >= 0 {
|
||
g.Players[winner].Trophies += res.Trophies
|
||
g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round)
|
||
// Priority token (two-player rule): the winner hands it to the other
|
||
// player; a loser who held it keeps it; a draw leaves it put. At bigger
|
||
// tables the token instead walks the table each round (startShopRound).
|
||
if len(g.Players) == 2 && winner == g.PrioritySeat {
|
||
g.PrioritySeat = (winner + 1) % len(g.Players)
|
||
}
|
||
}
|
||
// The result line names the table it came from, since several resolve at once.
|
||
loser := res.SeatOf(0)
|
||
if loser == winner {
|
||
loser = res.SeatOf(1)
|
||
}
|
||
if winner < 0 {
|
||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||
Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))})
|
||
} else {
|
||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||
Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)})
|
||
}
|
||
for _, seat := range res.Seats {
|
||
p := g.Players[seat]
|
||
side := res.Side(seat)
|
||
// Unicorn pack: persist the Mana pool as it stood at battle's end, and
|
||
// bank any apples destined for next round's hand (Skeleton Dog).
|
||
if side < len(res.ManaAfter) {
|
||
p.Mana = res.ManaAfter[side]
|
||
}
|
||
if side < len(res.NextRoundApples) {
|
||
p.NextRoundApples += res.NextRoundApples[side]
|
||
}
|
||
}
|
||
}
|
||
|
||
// runBattle plays one pairing's simulation to completion, returning the
|
||
// result. It mutates no persistent player state — that is finalizeBattle's job.
|
||
//
|
||
// first and second are the seats fighting, first having priority. Everything
|
||
// below works in *side* indices — 0 is first, 1 is second — so the resolver
|
||
// only ever deals with two combatants no matter how big the table is; res.Seats
|
||
// maps back out. Read `seat` in this function as "side" throughout.
|
||
func (g *Game) runBattle(first, second int) *BattleResult {
|
||
const n = 2 // sides in a battle, not players at the table
|
||
seats := []int{first, second}
|
||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats,
|
||
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 side, for log text.
|
||
pname := func(side int) string { return g.Players[seats[side]].Name }
|
||
|
||
for side, seat := range seats {
|
||
p := g.Players[seat]
|
||
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||
// Unicorn pack: the persistent Mana pool comes into battle (read-only
|
||
// here; written back by finalizeBattle so re-runs stay deterministic).
|
||
s.mana = p.Mana
|
||
sides[side] = s
|
||
res.StackSizes[side] = len(p.Deck)
|
||
res.Lineups[side] = append([]Card(nil), p.Deck...)
|
||
}
|
||
// enemyOf returns the opposing side.
|
||
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
|
||
// seatOrder resolves the first player before the second. Reveals, queued
|
||
// play effects, and cross-side triggers all follow it, so when two pets
|
||
// would act simultaneously (e.g. both throwing rocks) the first player acts
|
||
// first — its rocks can faint the enemy pet before that pet's own queued
|
||
// rocks resolve. Side 0 is the first player by construction.
|
||
seatOrder := []int{0, 1}
|
||
// 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 side, seat := range seats {
|
||
p := g.Players[seat]
|
||
for _, c := range p.Deck {
|
||
for _, e := range c.Effects {
|
||
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||
for range e.count() {
|
||
startApple(side)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||
// round (read-only here; resolveBattles clears it once the round's
|
||
// battles end, so re-runs bank the same amount).
|
||
for range p.PendingApplesInPlay {
|
||
startApple(side)
|
||
}
|
||
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||
if p.PendingTrumpets > 0 {
|
||
sides[side].trumpets += p.PendingTrumpets
|
||
emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets,
|
||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), 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))})
|
||
}
|
||
// gainMana adds Mana to a side's persistent pool and narrates it.
|
||
gainMana := func(seat, n int, cause string) {
|
||
if n <= 0 {
|
||
return
|
||
}
|
||
sides[seat].mana += n
|
||
emit(BattleEvent{Type: "mana", Seat: seat, Count: n,
|
||
Text: fmt.Sprintf("%s gains %d Mana (now %d).", cause, n, sides[seat].mana)})
|
||
}
|
||
// spend pays an effect's Trumpet and Mana costs from a side's pools,
|
||
// narrating each. Returns false (paying nothing) when the side can't afford
|
||
// the full cost, so the caller skips the effect. A cost-free effect always
|
||
// "pays".
|
||
spend := func(seat int, e Effect, cause string) bool {
|
||
s := sides[seat]
|
||
if s.trumpets < e.CostTrumpet || s.mana < e.CostMana {
|
||
return false
|
||
}
|
||
if e.CostTrumpet > 0 {
|
||
s.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))})
|
||
}
|
||
if e.CostMana > 0 {
|
||
s.mana -= e.CostMana
|
||
emit(BattleEvent{Type: "mana", Seat: seat, Count: -e.CostMana,
|
||
Text: fmt.Sprintf("%s spends %d Mana.", cause, e.CostMana)})
|
||
}
|
||
return true
|
||
}
|
||
// unicornConvert consumes one of side `s`'s Unicorn guards, if any, turning
|
||
// an incoming ailment into 2 Apples on top of that side's deck. Returns true
|
||
// when a guard fired (so the caller skips applying the ailment).
|
||
unicornConvert := func(seat int) bool {
|
||
s := sides[seat]
|
||
if len(s.unicornGuards) == 0 {
|
||
return false
|
||
}
|
||
card := s.unicornGuards[len(s.unicornGuards)-1]
|
||
s.unicornGuards = s.unicornGuards[:len(s.unicornGuards)-1]
|
||
emit(BattleEvent{Type: "release", Seat: seat, Card: &card,
|
||
Text: fmt.Sprintf("%s's Unicorn turns an Ailment into 2 Apples.", pname(seat))})
|
||
for range 2 {
|
||
apple := g.newApple()
|
||
s.stack = append([]Card{apple}, s.stack...)
|
||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &apple,
|
||
Text: fmt.Sprintf("%s adds an apple on top of %s's deck.", pname(seat), pname(seat))})
|
||
}
|
||
return true
|
||
}
|
||
// addAilment (Unicorn pack) afflicts the enemy of `from`. toDeck drops the
|
||
// ailment cards on top of the enemy deck (they snare the next pet revealed);
|
||
// otherwise they attach to the enemy pet in play now. A friendly Unicorn
|
||
// guard converts each incoming ailment to 2 apples; Baku eats one on a pet.
|
||
addAilment := func(from int, kind string, count int, toDeck bool, cause string) {
|
||
if count <= 0 {
|
||
return
|
||
}
|
||
target := (from + 1) % n
|
||
ts := sides[target]
|
||
if toDeck {
|
||
for range count {
|
||
if unicornConvert(target) {
|
||
continue
|
||
}
|
||
a := g.newAilment(kind)
|
||
ts.stack = append([]Card{a}, ts.stack...)
|
||
emit(BattleEvent{Type: "summon", Seat: target, Card: &a,
|
||
Text: fmt.Sprintf("%s adds %s on top of %s's deck.", cause, a.Name, pname(target))})
|
||
}
|
||
return
|
||
}
|
||
if ts.unit == nil {
|
||
return
|
||
}
|
||
rep := g.newAilment(kind)
|
||
landed, guarded := 0, 0
|
||
for range count {
|
||
if unicornConvert(target) {
|
||
continue
|
||
}
|
||
if ts.unit.takeAilment(kind) {
|
||
landed++
|
||
} else {
|
||
guarded++
|
||
}
|
||
}
|
||
if landed == 0 && guarded == 0 {
|
||
return // every ailment was converted to apples; nothing to narrate here
|
||
}
|
||
txt := fmt.Sprintf("%s afflicts %s with %d %s.", cause, ts.unit.Card.Name, landed, rep.Name)
|
||
if landed == 0 {
|
||
txt = fmt.Sprintf("%s shrugs off the %s.", ts.unit.Card.Name, rep.Name)
|
||
} else if guarded > 0 {
|
||
txt = fmt.Sprintf("%s afflicts %s with %d %s (one shrugged off).", cause, ts.unit.Card.Name, landed, rep.Name)
|
||
}
|
||
emit(BattleEvent{Type: "ailment", Seat: target, Card: &rep, Count: landed, Text: txt})
|
||
}
|
||
|
||
// 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
|
||
case ConditionEvenRound:
|
||
return g.Round%2 == 0
|
||
}
|
||
return true
|
||
}
|
||
|
||
// effAilment returns a seat's pet's effective Spooked/Exposed values,
|
||
// including any boost from an enemy Manticore set aside on the other side.
|
||
// A pet with none of that ailment gets no boost. (Unicorn pack.)
|
||
effAilment := func(seat, base int) int {
|
||
if base == 0 {
|
||
return 0
|
||
}
|
||
return base + sides[(seat+1)%n].ailmentBoost
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// Unicorn pack: Exposed raises the incoming damage of a landed hit. A
|
||
// shield still blocks the whole (raised) hit; Garlic/preventions shave it.
|
||
amount += effAilment(seat, u.Exposed)
|
||
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++
|
||
}
|
||
// Unicorn pack: a Fairy set aside BEFORE this faint recycles the fallen
|
||
// pet to the bottom of the deck. Snapshot the count now so a Fairy arming
|
||
// on THIS faint doesn't recycle itself.
|
||
fairyBefore := len(s.fairyGuards)
|
||
// 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)})
|
||
}
|
||
// Unicorn pack: a set-aside Mandrake on the enemy side cancels this
|
||
// pet's Faint ability (the pet still faints; its effects just don't run).
|
||
if es := enemyOf(seat); len(es.negators) > 0 {
|
||
hasFaint := false
|
||
for _, e := range u.effects() {
|
||
if e.Trigger == TriggerFaint {
|
||
hasFaint = true
|
||
break
|
||
}
|
||
}
|
||
if hasFaint {
|
||
m := es.negators[len(es.negators)-1]
|
||
es.negators = es.negators[:len(es.negators)-1]
|
||
emit(BattleEvent{Type: "release", Seat: (seat + 1) % n, Card: &m,
|
||
Text: fmt.Sprintf("%s's Mandrake cancels %s's faint ability.", pname((seat+1)%n), u.Card.Name)})
|
||
goto enemyReactions
|
||
}
|
||
}
|
||
{
|
||
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()
|
||
case ActionGainMana:
|
||
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||
case ActionAddAilment:
|
||
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
|
||
case ActionNextRoundApple:
|
||
// Banked for next round's hand; surfaced then in the shop log
|
||
// rather than as a battle-board change now.
|
||
s.nextRoundApples += e.count()
|
||
case ActionNegateEnemyFaint:
|
||
s.negators = append(s.negators, u.Card)
|
||
setAside()
|
||
case ActionReviveSelf:
|
||
// Slime: put a plain copy back on top of the deck — no faint
|
||
// ability, so it can't loop. "Once per round" falls out of that.
|
||
revived := u.Card
|
||
revived.ID = g.newCardID()
|
||
revived.Effects = nil
|
||
revived.EffectText = ""
|
||
summon(seat, revived, cause)
|
||
case ActionAilmentToApples:
|
||
s.unicornGuards = append(s.unicornGuards, u.Card)
|
||
setAside()
|
||
case ActionSmallPetAura:
|
||
s.smallPetBonus += e.count()
|
||
setAside()
|
||
case ActionAilmentBoost:
|
||
s.ailmentBoost += e.count()
|
||
setAside()
|
||
case ActionManaFeedOnPlay:
|
||
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
|
||
setAside()
|
||
case ActionReviveNextFaint:
|
||
s.fairyGuards = append(s.fairyGuards, u.Card)
|
||
setAside()
|
||
case ActionSummonFromDiscard:
|
||
// Chimera: add Count random cards from the FromTier discard pile as
|
||
// temporary copies on top of the deck.
|
||
pile := g.Discards[e.FromTier]
|
||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||
if len(pile) == 0 {
|
||
break
|
||
}
|
||
pick := pile[g.battleDraw(len(pile))]
|
||
copyC := pick
|
||
copyC.ID = g.newCardID()
|
||
copyC.Temporary = true
|
||
summon(seat, copyC, cause)
|
||
}
|
||
case ActionSummonFromTierDeck:
|
||
// Pixiu: a temporary copy of the top of the FromTier shop deck.
|
||
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
|
||
deck := g.ShopDecks[e.FromTier-1]
|
||
if len(deck) > 0 {
|
||
copyC := deck[0]
|
||
copyC.ID = g.newCardID()
|
||
copyC.Temporary = true
|
||
summon(seat, copyC, cause)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
enemyReactions:
|
||
// Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck
|
||
// bottom (a fresh copy, so it re-enters later and can faint again).
|
||
if fairyBefore > 0 {
|
||
s.fairyGuards = s.fairyGuards[:len(s.fairyGuards)-1]
|
||
revived := u.Card
|
||
revived.ID = g.newCardID()
|
||
s.stack = append(s.stack, revived)
|
||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &revived,
|
||
Text: fmt.Sprintf("A set-aside Fairy sends %s to the bottom of %s's deck.", u.Card.Name, pname(seat))})
|
||
}
|
||
// 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{})
|
||
}
|
||
case ActionHeal:
|
||
// Unicorn pack: Health Potion perk removes damage markers.
|
||
healed := min(e.count(), u.Damage)
|
||
if healed > 0 {
|
||
u.Damage -= healed
|
||
emit(BattleEvent{Type: "heal", Seat: seat, DamageAfter: u.Damage,
|
||
Text: fmt.Sprintf("%s heals %d after being hurt.", u.Card.Name, healed)})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// afterAttack fires After-Attack effects on a pet that survived a clash it
|
||
// fought in (Bulldog eats an apple once per battle; Behemoth every clash).
|
||
afterAttack := func(seat int, u *BattleUnit) {
|
||
if !u.Alive() {
|
||
return
|
||
}
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
|
||
continue
|
||
}
|
||
// Bulldog's charge is once per battle; Behemoth's fires every clash.
|
||
if e.Once && u.afterAttackUsed {
|
||
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)})
|
||
}
|
||
if e.Once {
|
||
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 and how
|
||
// many dice came up blank (a 0), for Tatzelwurm's mana payout.
|
||
throwRocks := func(from, target, dice int, source *Card) (killed bool, blanks int, dealt int) {
|
||
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, 0, 0
|
||
}
|
||
roll := 0
|
||
faces := make([]int, dice)
|
||
for i := range dice {
|
||
faces[i] = g.rollRockDie()
|
||
roll += faces[i]
|
||
if faces[i] == 0 {
|
||
blanks++
|
||
}
|
||
}
|
||
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, blanks, dealt
|
||
}
|
||
if dealt > 0 {
|
||
hurt(target, tu)
|
||
}
|
||
return false, blanks, dealt
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if c.IsAilment() {
|
||
// Unicorn pack: an ailment on top of the deck (Frost Wolf, Slime)
|
||
// waits to attach to the next pet revealed on this side.
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s's %s waits for the next pet.", pname(seat), c.Name)})
|
||
s.pendingAilments = append(s.pendingAilments, c)
|
||
continue
|
||
}
|
||
// Unicorn pack: Sleipnir's base Power becomes the owner's Mana
|
||
// (capped) at the moment it is played.
|
||
for _, e := range c.Effects {
|
||
if e.Trigger == TriggerPassive && e.Action == ActionManaPower {
|
||
c.Power = s.mana
|
||
if e.Cap > 0 && c.Power > e.Cap {
|
||
c.Power = e.Cap
|
||
}
|
||
}
|
||
}
|
||
u := &BattleUnit{Card: c, Foods: s.pending}
|
||
u.Bonus += appleCount(s.pending)
|
||
u.Bonus += s.petBonus
|
||
if isBee(c) {
|
||
u.Bonus += s.beeBonus
|
||
}
|
||
// Unicorn pack: Rootlin buffs small pets (base Power <= 2).
|
||
if c.Power <= 2 {
|
||
u.Bonus += s.smallPetBonus
|
||
}
|
||
// Unicorn pack: Baku discards the first ailment it would take.
|
||
for _, e := range c.Effects {
|
||
if e.Trigger == TriggerPassive && e.Action == ActionAilmentImmune {
|
||
u.bakuGuard = true
|
||
}
|
||
}
|
||
// Ailments waiting on this deck attach now, respecting Baku's
|
||
// guard; each landing is narrated so the client updates its badges.
|
||
for _, a := range s.pendingAilments {
|
||
landed := 0
|
||
if u.takeAilment(a.Ailment) {
|
||
landed = 1
|
||
}
|
||
ac := a
|
||
emit(BattleEvent{Type: "ailment", Seat: seat, Card: &ac, Count: landed,
|
||
Text: fmt.Sprintf("%s's %s latches onto %s.", pname(seat), a.Name, c.Name)})
|
||
}
|
||
s.pendingAilments = nil
|
||
// 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)})
|
||
}
|
||
// Unicorn pack: Team Spirit spends 1 Mana per set-aside to feed the
|
||
// just-played pet its apples (mandatory when affordable).
|
||
for i := range s.manaFeed {
|
||
if s.mana <= 0 {
|
||
break
|
||
}
|
||
s.mana--
|
||
src := s.manaFeed[i].src
|
||
emit(BattleEvent{Type: "mana", Seat: seat, Count: -1,
|
||
Text: fmt.Sprintf("%s spends 1 Mana.", src.Name)})
|
||
for range s.manaFeed[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.manaFeed[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
|
||
// Index-based so effects queued mid-loop (Abomination mimicking a Play
|
||
// ability) are resolved in the same pass.
|
||
for i := 0; i < len(plays); i++ {
|
||
q := plays[i]
|
||
// 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 killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
case q.effect.Target == "self":
|
||
// Manatee pelts its own pet.
|
||
if killed, _, _ := throwRocks(q.seat, q.seat, dice, q.source); killed {
|
||
anyDeath = true
|
||
}
|
||
default:
|
||
if t := nextTarget(q.seat); t >= 0 {
|
||
if killed, _, _ := throwRocks(q.seat, t, dice, q.source); killed {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
}
|
||
if q.release != nil {
|
||
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||
}
|
||
case ActionThrowRockGainMana:
|
||
// Tatzelwurm: throw the rocks, then gain 1 Mana per blank rolled.
|
||
dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||
if t := nextTarget(q.seat); t >= 0 {
|
||
killed, blanks, _ := throwRocks(q.seat, t, dice, q.source)
|
||
if killed {
|
||
anyDeath = true
|
||
}
|
||
gainMana(q.seat, blanks, costName)
|
||
}
|
||
case ActionGainMana:
|
||
gainMana(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||
case ActionAddAilment:
|
||
addAilment(q.seat, q.effect.Ailment, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), q.effect.Target == "enemyDeck", costName)
|
||
// Calygreyhound also eats apples alongside the spook.
|
||
if q.effect.Eat > 0 && q.unit != nil {
|
||
for range q.effect.Eat {
|
||
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 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:
|
||
// Unicorn pack: Mothman only eats if the enemy pet is ailing.
|
||
if q.effect.Condition == ConditionEnemyAilment {
|
||
et := nextTarget(q.seat)
|
||
if et < 0 || sides[et].unit == nil || !sides[et].unit.hasAilment() {
|
||
continue
|
||
}
|
||
}
|
||
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 killed, _, _ := throwRocks(q.seat, t, 2*choice, nil); killed {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
}
|
||
case ActionRockThenEat:
|
||
// Vampire Bat: (if the enemy is ailing) throw rocks, then eat apples
|
||
// equal to the damage those rocks actually dealt (Exposed counts).
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
if q.effect.Condition == ConditionEnemyAilment && !sides[t].unit.hasAilment() {
|
||
continue
|
||
}
|
||
dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||
killed, _, dealt := throwRocks(q.seat, t, dice, nil)
|
||
if killed {
|
||
anyDeath = true
|
||
}
|
||
if dealt > 0 && sides[q.seat].unit == q.unit {
|
||
for range dealt {
|
||
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 %d apple%s from the damage dealt (now +%d).", q.unit.Card.Name, dealt, plural(dealt), q.unit.Bonus)})
|
||
}
|
||
case ActionBounceEnemy:
|
||
// Loveland Frogman: send the enemy pet to the bottom of the enemy
|
||
// deck as a fresh body with its Play ability stripped (so two of them
|
||
// can't bounce each other forever).
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
bounced := tu.Card
|
||
bounced.ID = g.newCardID()
|
||
var keep []Effect
|
||
for _, ef := range bounced.Effects {
|
||
if ef.Trigger != TriggerPlay {
|
||
keep = append(keep, ef)
|
||
}
|
||
}
|
||
bounced.Effects = keep
|
||
sides[t].stack = append(sides[t].stack, bounced)
|
||
sides[t].unit = nil
|
||
emit(BattleEvent{Type: "bounce", Seat: q.seat, Target: t, Card: &bounced,
|
||
Text: fmt.Sprintf("%s sends %s to the bottom of %s's deck.", q.unit.Card.Name, tu.Card.Name, pname(t))})
|
||
anyDeath = true // force a refill before the clash
|
||
case ActionSpendManaRocks:
|
||
// Sea Serpent: spend all Mana to throw that many rocks.
|
||
s := sides[q.seat]
|
||
choice := s.mana
|
||
if choice > 0 {
|
||
s.mana -= choice
|
||
emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice,
|
||
Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)})
|
||
if t := nextTarget(q.seat); t >= 0 {
|
||
if killed, _, _ := throwRocks(q.seat, t, choice, nil); killed {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
}
|
||
case ActionSpendManaSpook:
|
||
// Bakunawa: spend all Mana to add that many Spooked to the enemy pet.
|
||
s := sides[q.seat]
|
||
choice := s.mana
|
||
if choice > 0 {
|
||
s.mana -= choice
|
||
emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice,
|
||
Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)})
|
||
addAilment(q.seat, AilmentSpooked, choice, false, costName)
|
||
}
|
||
case ActionGainAbility:
|
||
// Abomination: copy a random pet's effects from the highest tier
|
||
// discard pile. The copied effects join Abomination's own (so its
|
||
// Faint/Hurt abilities fire later), and any copied Play ability
|
||
// resolves right now — it's queued onto this same pass.
|
||
bestTier := 0
|
||
for tier, pile := range g.Discards {
|
||
if tier <= bestTier {
|
||
continue
|
||
}
|
||
for _, dc := range pile {
|
||
if dc.IsPet() {
|
||
bestTier = tier
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if bestTier > 0 {
|
||
var pets []Card
|
||
for _, dc := range g.Discards[bestTier] {
|
||
if dc.IsPet() {
|
||
pets = append(pets, dc)
|
||
}
|
||
}
|
||
if len(pets) > 0 {
|
||
pick := pets[g.battleDraw(len(pets))]
|
||
q.unit.Card.Effects = append(append([]Effect(nil), q.unit.Card.Effects...), pick.Effects...)
|
||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||
Text: fmt.Sprintf("%s mimics %s's ability.", q.unit.Card.Name, pick.Name)})
|
||
// Fire the mimicked Play abilities immediately. Skip another
|
||
// gainAbility so mimicking can't chain into an infinite loop,
|
||
// and skip the enter-time shields (armed only on reveal).
|
||
for _, pe := range pick.Effects {
|
||
if pe.Trigger != TriggerPlay || pe.Action == ActionGainAbility ||
|
||
pe.Action == ActionShieldSelf || pe.Action == ActionPreventSelf {
|
||
continue
|
||
}
|
||
plays = append(plays, queuedPlay{seat: q.seat, unit: q.unit, effect: pe})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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: the two sides' pets trade blows. A is the first player's side.
|
||
ua, ub := sides[0].unit, sides[1].unit
|
||
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
|
||
// to the defender inside hitUnit); a Manticore boosts enemy ailments.
|
||
powA := max(0, ua.Power()-effAilment(0, ua.Spooked))
|
||
powB := max(0, ub.Power()-effAilment(1, ub.Spooked))
|
||
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 that can still field a pet wins; anything else (both out,
|
||
// or a stalemate with pets on both sides) is a draw. We test canField,
|
||
// not unit, because the loop can break the instant one side runs out while
|
||
// the other's current pet has just fainted — that side still has pets left
|
||
// in its stack (it simply wasn't refilled) and is the rightful winner.
|
||
winner := -1
|
||
for side, s := range sides {
|
||
if s.canField() {
|
||
if winner >= 0 {
|
||
winner = -1 // both still standing: a stalemate draw
|
||
break
|
||
}
|
||
winner = side
|
||
}
|
||
}
|
||
// The winner leaves this function as a seat at the table, the one piece of
|
||
// the result that means anything outside the battle.
|
||
if winner >= 0 {
|
||
res.WinnerSeat = seats[winner]
|
||
// The last round is worth double.
|
||
res.Trophies = 1
|
||
if g.Round == MaxRounds {
|
||
res.Trophies = 2
|
||
}
|
||
}
|
||
|
||
// Record each side's leftover force: a live pet in play plus any pets never
|
||
// reached in the stack. The loser lands on 0.
|
||
res.Survivors = make([]int, n)
|
||
res.ManaAfter = make([]int, n)
|
||
res.NextRoundApples = make([]int, n)
|
||
for seat, s := range sides {
|
||
cnt := 0
|
||
if s.unit != nil && s.unit.Alive() {
|
||
cnt++
|
||
}
|
||
for _, c := range s.stack {
|
||
if c.IsPet() {
|
||
cnt++
|
||
}
|
||
}
|
||
res.Survivors[seat] = cnt
|
||
res.ManaAfter[seat] = s.mana
|
||
res.NextRoundApples[seat] = s.nextRoundApples
|
||
}
|
||
return res
|
||
}
|