Initial pass at unicorn pack.
This commit is contained in:
+486
-23
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user