Initial pass at unicorn pack.

This commit is contained in:
Greyson Parrelli
2026-07-25 00:37:44 -04:00
parent f3783b44bf
commit a1d57fe1dd
21 changed files with 2419 additions and 44 deletions
+486 -23
View File
@@ -20,6 +20,12 @@ type BattleUnit struct {
// 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
@@ -51,6 +57,25 @@ type preventInfo struct {
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 {
@@ -124,6 +149,11 @@ type BattleEvent struct {
// 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].
@@ -165,6 +195,11 @@ type BattleResult struct {
// 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 seat's persistent Mana pool once the
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
// is apples each seat banked for next round's hand (Skeleton Dog).
ManaAfter []int `json:"manaAfter,omitempty"`
NextRoundApples []int `json:"nextRoundApples,omitempty"`
}
// setAsideRocks is a fainted pet's pending rock payout.
@@ -214,6 +249,19 @@ type battleSide struct {
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.
@@ -324,6 +372,14 @@ func (g *Game) finalizeBattle(res *BattleResult) {
for _, p := range g.Players {
p.PendingApplesInPlay = 0
p.PendingTrumpets = 0
// 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 res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
p.Mana = res.ManaAfter[p.Seat]
}
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
p.NextRoundApples += res.NextRoundApples[p.Seat]
}
}
}
@@ -339,6 +395,9 @@ func (g *Game) runBattle() *BattleResult {
for _, p := range g.Players {
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[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck)
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
@@ -418,21 +477,104 @@ func (g *Game) runBattle() *BattleResult {
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
// gainMana adds Mana to a side's persistent pool and narrates it.
gainMana := func(seat, n int, cause string) {
if n <= 0 {
return
}
if sides[seat].trumpets < e.CostTrumpet {
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
}
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))})
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 {
@@ -444,10 +586,22 @@ func (g *Game) runBattle() *BattleResult {
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
@@ -458,6 +612,9 @@ func (g *Game) runBattle() *BattleResult {
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
@@ -539,6 +696,10 @@ func (g *Game) runBattle() *BattleResult {
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 != "" {
@@ -551,6 +712,25 @@ func (g *Game) runBattle() *BattleResult {
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) {
@@ -643,8 +823,79 @@ func (g *Game) runBattle() *BattleResult {
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() {
@@ -699,20 +950,32 @@ func (g *Game) runBattle() *BattleResult {
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). Effects here are once per battle.
// fought in (Bulldog eats an apple once per battle; Behemoth every clash).
afterAttack := func(seat int, u *BattleUnit) {
if !u.Alive() || u.afterAttackUsed {
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
}
@@ -725,24 +988,30 @@ func (g *Game) runBattle() *BattleResult {
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
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.
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
// 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
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()
@@ -790,12 +1059,12 @@ func (g *Game) runBattle() *BattleResult {
}
faint(target, tu)
sides[target].unit = nil
return true
return true, blanks, dealt
}
if dealt > 0 {
hurt(target, tu)
}
return false
return false, blanks, dealt
}
// nextTarget finds the seat whose pet a standard enemy-directed play
@@ -837,12 +1106,52 @@ func (g *Game) runBattle() *BattleResult {
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)
@@ -892,6 +1201,23 @@ func (g *Game) runBattle() *BattleResult {
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).
@@ -1020,18 +1346,18 @@ func (g *Game) runBattle() *BattleResult {
switch {
case q.everyone:
for seat := range sides {
if throwRocks(q.seat, seat, dice, q.source) {
if killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed {
anyDeath = true
}
}
case q.effect.Target == "self":
// Manatee pelts its own pet.
if throwRocks(q.seat, q.seat, dice, q.source) {
if killed, _, _ := throwRocks(q.seat, q.seat, dice, q.source); killed {
anyDeath = true
}
default:
if t := nextTarget(q.seat); t >= 0 {
if throwRocks(q.seat, t, dice, q.source) {
if killed, _, _ := throwRocks(q.seat, t, dice, q.source); killed {
anyDeath = true
}
}
@@ -1039,6 +1365,29 @@ func (g *Game) runBattle() *BattleResult {
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:
@@ -1159,6 +1508,13 @@ func (g *Game) runBattle() *BattleResult {
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 {
@@ -1193,11 +1549,111 @@ func (g *Game) runBattle() *BattleResult {
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) {
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. Gained abilities fire on their natural triggers going
// forward; a gained Play ability doesn't retro-fire this turn.
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)})
}
}
}
}
// Battle over? A side that can no longer field a pet is out (checked
@@ -1219,7 +1675,10 @@ func (g *Game) runBattle() *BattleResult {
// Clash. Two-player for now; >2-player battle pairings come later
// (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit
powA, powB := ua.Power(), ub.Power()
// 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.
@@ -1321,6 +1780,8 @@ func (g *Game) runBattle() *BattleResult {
// 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() {
@@ -1332,6 +1793,8 @@ func (g *Game) runBattle() *BattleResult {
}
}
res.Survivors[seat] = cnt
res.ManaAfter[seat] = s.mana
res.NextRoundApples[seat] = s.nextRoundApples
}
return res
}