Initial pass at Golden Pack.

This commit is contained in:
Greyson Parrelli
2026-07-24 07:47:05 -04:00
parent e74f983470
commit dd395f4bbf
27 changed files with 2770 additions and 150 deletions
+507 -68
View File
@@ -1,6 +1,9 @@
package game
import "fmt"
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.
@@ -13,6 +16,11 @@ type BattleUnit struct {
// 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
@@ -32,6 +40,15 @@ type shieldBlock struct {
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() }
@@ -160,6 +177,13 @@ type lastPetVolley struct {
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
@@ -175,6 +199,17 @@ type battleSide struct {
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.
@@ -187,6 +222,17 @@ func (s *battleSide) hasPetInStack() bool {
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
@@ -198,8 +244,9 @@ type queuedPlay struct {
}
// effectCount resolves an effect's final count: base × Per statistic,
// limited by Cap.
func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
// 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:
@@ -210,6 +257,14 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
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
@@ -231,20 +286,72 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
// 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 re-runs the (deterministic) simulation
// from the recorded dice/decision tapes, publishing either a completed result
// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark).
func (g *Game) resolveBattle() {
g.NextCardID = g.BattleCardBase
g.battleRollCursor = 0
g.battleDecisionCursor = 0
res, pending := g.runBattle()
g.Battle = res
if pending != nil {
g.PendingBattle = pending
return
}
g.PendingBattle = nil
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. Deferred here (not inside runBattle) because runBattle
// may re-run several times before the battle actually completes.
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 or until it needs a mid-battle
// decision, returning the (partial) result and a non-nil pending in the latter
// case. It mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
n := len(g.Players)
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
var suspended *PendingBattleDecision
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...)}
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
@@ -257,6 +364,13 @@ func (g *Game) resolveBattle() {
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 {
@@ -264,14 +378,23 @@ func (g *Game) resolveBattle() {
for _, e := range c.Effects {
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
for range e.count() {
apple := g.newApple()
sides[p.Seat].pending = append(sides[p.Seat].pending, apple)
emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple,
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))})
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) {
@@ -280,12 +403,43 @@ func (g *Game) resolveBattle() {
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 {
@@ -306,10 +460,10 @@ func (g *Game) resolveBattle() {
// 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) {
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) {
u := sides[seat].unit
if u == nil || amount <= 0 {
return 0, nil
return 0, nil, nil
}
if sides[seat].shields > 0 {
sides[seat].shields--
@@ -321,16 +475,36 @@ func (g *Game) resolveBattle() {
card = &c
source = c.Name
}
return 0, &shieldBlock{source: source, release: card}
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}
return 0, &shieldBlock{source: sc.source, release: sc.card}, nil
}
dealt = max(0, amount-u.prevention())
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
return dealt, nil, prevent
}
// emitShield narrates a blocked hit — naming the source rather than a bare
@@ -347,6 +521,21 @@ func (g *Game) resolveBattle() {
}
}
// 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).
@@ -357,6 +546,11 @@ func (g *Game) resolveBattle() {
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() {
@@ -364,19 +558,47 @@ func (g *Game) resolveBattle() {
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) {
summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name))
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 {
@@ -385,6 +607,28 @@ func (g *Game) resolveBattle() {
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})
@@ -417,7 +661,7 @@ func (g *Game) resolveBattle() {
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
continue
}
healed := min(effectCount(e, os, os.unit), os.unit.Damage)
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,
@@ -436,18 +680,23 @@ func (g *Game) resolveBattle() {
if e.Trigger != TriggerHurt || !allowed(e, u) {
continue
}
if !spend(seat, e, u.Card.Name) {
continue
}
switch e.Action {
case ActionEatApple:
for range effectCount(e, sides[seat], u) {
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) {
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() {
@@ -457,6 +706,32 @@ func (g *Game) resolveBattle() {
}
}
// 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) {
@@ -470,7 +745,7 @@ func (g *Game) resolveBattle() {
faces[i] = g.rollRockDie()
roll += faces[i]
}
dealt, block := hitUnit(target, roll)
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
@@ -506,6 +781,9 @@ func (g *Game) resolveBattle() {
if block != nil {
emitShield(target, tu.Card.Name, block)
}
if prev != nil {
emitPrevent(target, tu.Card.Name, prev)
}
if died {
faint(target, tu)
sides[target].unit = nil
@@ -571,6 +849,7 @@ func (g *Game) resolveBattle() {
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.
@@ -585,6 +864,31 @@ func (g *Game) resolveBattle() {
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).
@@ -593,8 +897,11 @@ func (g *Game) resolveBattle() {
return
}
// Shields apply the instant the pet enters play, ahead of
// any queued rocks (Melon).
// 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 {
@@ -605,6 +912,13 @@ func (g *Game) resolveBattle() {
}
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 {
@@ -616,6 +930,24 @@ func (g *Game) resolveBattle() {
}
}
}
// 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.
@@ -655,20 +987,9 @@ func (g *Game) resolveBattle() {
}
}
// Battle over? A side that couldn't field a pet is out; no further
// effects resolve.
anyOut := false
for _, s := range sides {
if s.unit == nil {
anyOut = true
}
}
if anyOut {
break
}
// Resolve play effects. Any of these can faint a pet before the
// clash.
// 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.
@@ -678,26 +999,97 @@ func (g *Game) resolveBattle() {
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)
dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
}
if q.everyone {
switch {
case q.everyone:
for seat := range sides {
if throwRocks(q.seat, seat, dice, q.source) {
anyDeath = true
}
}
} else if t := nextTarget(q.seat); t >= 0 {
if throwRocks(q.seat, t, dice, q.source) {
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 {
@@ -758,11 +1150,11 @@ func (g *Game) resolveBattle() {
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) {
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)
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())
@@ -771,7 +1163,65 @@ func (g *Game) resolveBattle() {
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, shuffle apples into
// random deck positions (deterministic via the battle draw tape).
if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 {
continue
}
s := sides[q.seat]
for range q.effect.count() {
pos := g.battleDraw(len(s.stack) + 1)
apple := g.newApple()
s.stack = slices.Insert(s.stack, pos, 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))})
}
case ActionSpendRocks:
// Nurse Shark: the owner chooses how many Trumpets (0..available,
// capped at Count) to spend; each throws two rocks. This is the one
// mid-battle decision — it may suspend the whole simulation.
s := sides[q.seat]
maxSpend := min(q.effect.count(), s.trumpets)
choice, pending := g.decideBattle(PendingBattleDecision{
Seat: q.seat, Kind: "nurseShark", PetName: q.unit.Card.Name,
Min: 0, Max: maxSpend, Trumpets: s.trumpets,
})
if pending != nil {
suspended = pending
break
}
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
}
}
}
}
if suspended != nil {
break // stop mid-plays; the battle re-runs once the choice is in
}
}
// A pending decision unwinds the whole simulation; the events emitted so
// far are a valid prefix the re-run reproduces exactly.
if suspended != nil {
return res, suspended
}
// 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
@@ -781,8 +1231,8 @@ func (g *Game) resolveBattle() {
// (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit
powA, powB := ua.Power(), ub.Power()
dealtA, blockA := hitUnit(0, powB)
dealtB, blockB := hitUnit(1, powA)
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())
@@ -828,7 +1278,14 @@ func (g *Game) resolveBattle() {
if blockB != nil {
emitShield(1, ub.Card.Name, blockB)
}
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil {
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}
@@ -836,8 +1293,11 @@ func (g *Game) resolveBattle() {
if !u.Alive() {
faint(seat, u)
sides[seat].unit = nil
} else if dealt[seat] > 0 {
hurt(seat, u)
} else {
if dealt[seat] > 0 {
hurt(seat, u)
}
afterAttack(seat, u)
}
}
}
@@ -860,27 +1320,6 @@ func (g *Game) resolveBattle() {
if g.Round == MaxRounds {
res.Trophies = 2
}
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; the
// "other player" is unambiguous only at n == 2.)
if winner == g.PrioritySeat {
g.PrioritySeat = (winner + 1) % n
}
}
g.Battle = res
g.Phase = PhaseBattle
// Tagged "result" so the client can hold it back until the replay finishes
// (the outcome is known now, but showing it early would spoil the battle).
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🏆).", pname(winner), g.Round, res.Trophies)})
}
for _, p := range g.Players {
p.Ready = false
}
return res, nil
}