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
}
+440 -3
View File
@@ -19,6 +19,9 @@ type CardKind string
const (
KindPet CardKind = "pet"
KindFood CardKind = "food"
// KindAilment (Unicorn pack) is a temporary debuff card (Spooked, Exposed)
// attached to a pet during battle. It is neither a pet nor a food.
KindAilment CardKind = "ailment"
)
// Food identifiers.
@@ -37,6 +40,15 @@ const (
FoodPotato = "potato"
FoodDurian = "durian"
FoodTomato = "tomato"
// FoodFairyDust (Unicorn pack) is a perk food: the pet it's on gains 1 Mana
// when played. FoodWaterOfYouth is a one-shot shop food (not a perk) that
// upgrades a pet into a next-tier card.
FoodFairyDust = "fairyDust"
FoodWaterOfYouth = "waterOfYouth"
// Unicorn pack perk foods, tiers 4-6.
FoodHealthPotion = "healthPotion" // Hurt: heal 3 damage
FoodBigManaPotion = "bigManaPotion" // Play: gain 3 Mana
FoodCornucopia = "cornucopia" // Play: eat 2 Apples, add 2 on deck
)
// EffectTrigger is when an effect fires.
@@ -209,6 +221,91 @@ const (
// Retriever, when summoned, prevents Count damage on its first hit (German
// Shepherd).
ActionGuardRetriever EffectAction = "guardRetriever"
// --- Unicorn pack ---
// ActionGainMana adds Count Mana to the acting side. Mana is a persistent
// resource (unlike Trumpets): it is gained shop-time (Buy: Cuddle Toad,
// Thunderbird) or battle-time (Play/Faint: Alchemedes, Fur-Bearing Trout),
// kept between rounds, and spent via CostMana (see Effect.CostMana).
ActionGainMana EffectAction = "gainMana"
// ActionAddAilment attaches Count Ailment cards (Effect.Ailment: "spooked" |
// "exposed") to the enemy. Target "" hits the enemy pet in play; Target
// "enemyDeck" puts them on top of the enemy deck to snare the next pet. When
// Eat > 0 (Calygreyhound) the pet also eats that many apples. Battle-time.
ActionAddAilment EffectAction = "addAilment"
// ActionAilmentImmune (passive) makes the pet discard the first Ailment it
// would receive (or one it enters play with) — Baku.
ActionAilmentImmune EffectAction = "ailmentImmune"
// ActionThrowRockGainMana (play) throws Count rocks at the enemy pet, then
// gains 1 Mana for each rock that rolled a blank (a 0) — Tatzelwurm.
ActionThrowRockGainMana EffectAction = "throwRockGainMana"
// ActionReviveSelf (faint) puts a plain copy of this pet back on top of its
// owner's deck once per battle (the copy carries no faint ability, so it
// can't loop) — Slime.
ActionReviveSelf EffectAction = "reviveSelf"
// ActionNegateEnemyFaint (faint) sets the pet aside: the next enemy pet to
// faint has its Faint ability do nothing — Mandrake.
ActionNegateEnemyFaint EffectAction = "negateEnemyFaint"
// ActionNextRoundApple (faint) banks Count apples that land in the owner's
// hand at the start of the next round — Skeleton Dog.
ActionNextRoundApple EffectAction = "nextRoundApple"
// ActionPeekShop (passive) marks a pet whose owner may reveal it from hand to
// look at the top of the current shop deck, once per round — Bigfoot. It is
// an optional shop action (see Game.PeekShopDeck), never auto-fired.
ActionPeekShop EffectAction = "peekShop"
// ActionUpgradeNextTier (buy) discards this food and a pet from the player's
// hand to take the top card of the next tier's deck for free — Water of
// Youth. Shop-time; opens a PendingSacrifice choice.
ActionUpgradeNextTier EffectAction = "upgradeNextTier"
// --- Unicorn pack, tiers 4-6 ---
// ActionAilmentToApples (faint) sets the pet aside and arms a side guard: the
// next Ailment that would land on a friendly pet or friendly deck is replaced
// by 2 Apples on top of the owner's deck — Unicorn.
ActionAilmentToApples EffectAction = "ailmentToApples"
// ActionSmallPetAura (faint) sets the pet aside: friendly pets with base
// Power <= 2 get +Count Power for the rest of the battle — Rootlin.
ActionSmallPetAura EffectAction = "smallPetAura"
// ActionReviveNextFaint (faint) sets the pet aside (paying CostMana): the next
// time a friendly pet faints, a copy goes on the bottom of the owner's deck —
// Fairy.
ActionReviveNextFaint EffectAction = "reviveNextFaint"
// ActionGainAbility (play) reveals a random pet from the highest tier with
// discards and copies its effects onto this pet — Abomination.
ActionGainAbility EffectAction = "gainAbility"
// ActionSummonFromDiscard (faint, paying CostMana) adds Count random cards
// from the FromTier discard pile as temporary copies on top of the deck —
// Chimera.
ActionSummonFromDiscard EffectAction = "summonFromDiscard"
// ActionSummonFromTierDeck (faint, paying CostMana) adds a temporary copy of
// the top of the FromTier shop deck on top of the owner's deck — Pixiu.
ActionSummonFromTierDeck EffectAction = "summonFromTierDeck"
// ActionRockThenEat (play) throws Count rocks at the enemy pet, then feeds
// this pet Apples equal to the damage those rocks actually dealt (Exposed
// counts) — Vampire Bat. Usually gated on ConditionEnemyAilment.
ActionRockThenEat EffectAction = "rockThenEat"
// ActionBounceEnemy (play) sends the enemy pet to the bottom of the enemy
// deck as a fresh body (its Play ability stripped so it can't re-loop) —
// Loveland Frogman.
ActionBounceEnemy EffectAction = "bounceEnemy"
// ActionManaPower (passive) sets the pet's base Power to the owner's Mana at
// play time, capped at Cap — Sleipnir.
ActionManaPower EffectAction = "manaPower"
// ActionSpendManaRocks (play) spends all the owner's Mana to throw that many
// rocks at the enemy pet — Sea Serpent.
ActionSpendManaRocks EffectAction = "spendManaRocks"
// ActionSpendManaSpook (play) spends all the owner's Mana to add that many
// Spooked to the enemy pet — Bakunawa.
ActionSpendManaSpook EffectAction = "spendManaSpook"
// ActionAilmentBoost (faint) sets the pet aside: Ailments on enemy pets count
// for Count more — Manticore.
ActionAilmentBoost EffectAction = "ailmentBoost"
// ActionManaFeedOnPlay (faint) sets the pet aside: each time the owner plays a
// pet, spend 1 Mana to feed it Count Apples — Team Spirit.
ActionManaFeedOnPlay EffectAction = "manaFeedOnPlay"
// ActionRevealTierForApples (buy) opens a reveal of a hand pet at tier <= Cap
// for Count Apples — Quetzalcoatl. Shop-time.
ActionRevealTierForApples EffectAction = "revealTierForApples"
)
// Per multipliers for dynamic effect counts.
@@ -236,6 +333,19 @@ const (
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
ConditionHasPerk = "hasPerk" // this pet has a perk attached
ConditionFirstPet = "firstPet" // this is the first pet the side has played (Komodo)
// ConditionEnemyAilment gates a battle effect on the enemy pet in play
// carrying at least one Ailment (Unicorn pack: Mothman).
ConditionEnemyAilment = "enemyAilment"
// ConditionEvenRound gates a battle effect to even-numbered rounds (Unicorn
// pack: Werewolf).
ConditionEvenRound = "evenRound"
)
// Ailment kinds (Unicorn pack). Ailments are temporary debuff cards attached to
// a pet during battle; they stack and are removed at the end of the round.
const (
AilmentSpooked = "spooked" // the pet deals N less damage in a clash (min 0)
AilmentExposed = "exposed" // the pet takes N extra damage each time it is hit
)
// Effect is one trigger→action pair printed on a card.
@@ -257,6 +367,22 @@ type Effect struct {
// effect to fire (Golden pack). It's auto-paid when affordable and the
// effect is skipped otherwise — battles take no player input.
CostTrumpet int `json:"costTrumpet,omitempty"`
// CostMana, when > 0, is a Mana cost paid the same way as CostTrumpet
// (Unicorn pack). Unlike Trumpets, Mana persists across rounds.
CostMana int `json:"costMana,omitempty"`
// Ailment names the debuff an ActionAddAilment applies ("spooked" |
// "exposed"); Count is how many (Unicorn pack).
Ailment string `json:"ailment,omitempty"`
// Eat, on an ActionAddAilment, also feeds the pet that many apples
// (Calygreyhound spends 1 Mana to eat and spook in one ability).
Eat int `json:"eat,omitempty"`
// FromTier names a tier a battle effect pulls from — a discard pile (Chimera)
// or a shop deck (Pixiu) — 1-based (Unicorn pack).
FromTier int `json:"fromTier,omitempty"`
// Once marks an After-Attack effect that fires at most once per battle
// (Bulldog); without it the effect fires after every clash survived
// (Behemoth).
Once bool `json:"once,omitempty"`
// SurviveOnly marks a Hurt effect that fires only when the pet lives through
// the hit. By default a Hurt effect still fires on a fatal hit (the Lizard
// drops its Bee even when killed); self-buffs that are pointless once the
@@ -295,10 +421,14 @@ type Card struct {
// Temporary cards (apples, bees) are removed from the deck after the
// next battle.
Temporary bool `json:"temporary,omitempty"`
// Ailment marks a debuff card (Unicorn pack): "spooked" | "exposed". Set
// together with Kind == KindAilment.
Ailment string `json:"ailment,omitempty"`
}
func (c Card) IsPet() bool { return c.Kind == KindPet }
func (c Card) IsFood() bool { return c.Kind == KindFood }
func (c Card) IsPet() bool { return c.Kind == KindPet }
func (c Card) IsFood() bool { return c.Kind == KindFood }
func (c Card) IsAilment() bool { return c.Kind == KindAilment }
// petTemplate is the printed definition of a pet. Suits lists the suit of
// each physical copy in the tier deck (one card per entry).
@@ -613,7 +743,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{
},
{
Name: "Bulldog", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple}},
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Once: true}},
EffectText: "After Attacking: if it hasn't fainted, eat 1 Apple (once per round)",
},
{
@@ -853,6 +983,294 @@ var goldenFoodTiers = [MaxRounds][]foodTemplate{
},
}
// unicornPetTiers defines the Unicorn pack's pets. Tiers 1-3 are printed;
// tiers 4-6 are still being authored. Each pet ships as two copies (one per
// listed suit). The pack adds two mechanics the others don't have: Mana (a
// persistent resource spent to power abilities) and Ailments (Spooked/Exposed
// debuffs attached to enemy pets during battle).
var unicornPetTiers = [MaxRounds][]petTemplate{
{ // Tier 1
{
Name: "Alchemedes", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}},
EffectText: "Play: gain 1 Mana",
},
{
Name: "Cuddle Toad", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana}},
EffectText: "Buy: gain 1 Mana",
},
{
Name: "Pengobble", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, CostMana: 1}},
EffectText: "Play: spend 1 Mana to throw 2 Rocks",
},
{
Name: "Barghest", Power: 1, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked}},
EffectText: "Play: add 1 Spooked to the enemy pet",
},
{
Name: "Basilisk", Power: 1, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed}},
EffectText: "Play: add 1 Exposed to the enemy pet",
},
{
Name: "Baku", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionAilmentImmune}},
EffectText: "Discard the first Ailment added to this pet",
},
},
{ // Tier 2
{
Name: "Thunderbird", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana, Count: 2}},
EffectText: "Buy: gain 2 Mana",
},
{
Name: "Gargoyle", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2, CostMana: 1}},
EffectText: "Faint: spend 1 Mana to add 2 Apples on top of your deck",
},
{
Name: "Frost Wolf", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed},
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"},
},
EffectText: "Faint: add 1 Exposed to the enemy pet and 1 on top of the enemy deck",
},
{
Name: "Mothman", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 2, Condition: ConditionEnemyAilment}},
EffectText: "Play: if the enemy pet has an Ailment, eat 2 Apples",
},
{
Name: "Nightcrawler", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2}},
EffectText: "Play: add 2 Spooked to the enemy pet",
},
{
Name: "Bigfoot", Power: 3, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionPeekShop}},
EffectText: "Reveal from your hand to look at the top of the shop deck (once per round)",
},
},
{ // Tier 3
{
Name: "Fur-Bearing Trout", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{
{Trigger: TriggerFaint, Action: ActionGainMana},
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2},
},
EffectText: "Faint: gain 1 Mana and add 2 Apples on top of your deck",
},
{
Name: "Calygreyhound", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Eat: 1, CostMana: 1}},
EffectText: "Play: spend 1 Mana to eat 1 Apple and add 1 Spooked to the enemy pet",
},
{
Name: "Tatzelwurm", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRockGainMana, Count: 2}},
EffectText: "Play: throw 2 Rocks, then gain 1 Mana for each blank rolled",
},
{
Name: "Skeleton Dog", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"},
{Trigger: TriggerFaint, Action: ActionNextRoundApple},
},
EffectText: "Faint: add 1 Apple on top of your deck and 1 to your hand next round",
},
{
Name: "Mandrake", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionNegateEnemyFaint}},
EffectText: "Faint: set aside — the next enemy Faint ability does nothing",
},
{
Name: "Lucky Cat", Power: 3, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerTriple, Action: ActionGainApple, Count: 3}},
EffectText: "Triple: add 3 Apples to your hand",
},
{
Name: "Slime", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
Effects: []Effect{
{Trigger: TriggerFaint, Action: ActionReviveSelf},
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"},
},
EffectText: "Faint: put this pet on top of your deck and 1 Exposed on the enemy deck (once per round)",
},
},
{ // Tier 4
{
Name: "Roc", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionGainMana},
{Trigger: TriggerSell, Action: ActionGainMana},
},
EffectText: "Play: gain 1 Mana · Sell: gain 1 Mana",
},
{
Name: "Chimera", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromDiscard, Count: 2, FromTier: 1, CostMana: 4}},
EffectText: "Faint: spend 4 Mana to add 2 random cards from the tier 1 discard pile on top of your deck",
},
{
Name: "Kraken", Power: 4, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked},
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Target: "enemyDeck"},
},
EffectText: "Play: add 1 Spooked to the enemy pet and 1 on top of the enemy deck",
},
{
Name: "Unicorn", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentToApples}},
EffectText: "Faint: set aside — replace the next friendly Ailment with 2 Apples on your deck",
},
{
Name: "Abomination", Power: 4, Suits: []Suit{SuitYellow, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainAbility}},
EffectText: "Play: reveal a random pet from the highest tier discard pile and gain its ability",
},
{
Name: "Rootlin", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSmallPetAura, Count: 1}},
EffectText: "Faint: set aside — your pets with base Power 2 or less have +1 Power",
},
{
Name: "Fairy", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionReviveNextFaint, CostMana: 3}},
EffectText: "Faint: spend 3 Mana to set aside — the next friendly faint returns to the bottom of your deck",
},
},
{ // Tier 5
{
Name: "Kitsune", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Per: PerFaintedPets}},
EffectText: "Play: gain 1 Mana for each friendly fainted pet",
},
{
Name: "Pixiu", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromTierDeck, FromTier: 6, CostMana: 4}},
EffectText: "Faint: spend 4 Mana to add the top card of the tier 6 deck on top of your deck",
},
{
Name: "Red Dragon", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2},
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2, Target: "enemyDeck"},
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2},
},
EffectText: "Play: add 2 Exposed to the enemy pet and 2 on the enemy deck, then throw 2 Rocks",
},
{
Name: "Amalgamation", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedPets},
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2},
},
EffectText: "Play: eat 1 Apple per friendly fainted pet, then add 2 Spooked to the enemy pet",
},
{
Name: "Vampire Bat", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionRockThenEat, Count: 2, Condition: ConditionEnemyAilment}},
EffectText: "Play: if the enemy pet has an Ailment, throw 2 Rocks, then eat Apples equal to the damage dealt",
},
{
Name: "Werewolf", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 6, Condition: ConditionEvenRound}},
EffectText: "Play: on an even-numbered round, eat 6 Apples",
},
{
Name: "Loveland Frogman", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionBounceEnemy}},
EffectText: "Play: put the enemy pet on the bottom of the enemy deck",
},
},
{ // Tier 6
{
Name: "Sleipnir", Power: 0, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionManaPower, Cap: 9}},
EffectText: "This pet's base Power equals your Mana (max 9)",
},
{
Name: "Sea Serpent", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaRocks}},
EffectText: "Play: spend any number of Mana to throw that many Rocks",
},
{
Name: "Bakunawa", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaSpook}},
EffectText: "Play: spend any number of Mana to add that many Spooked to the enemy pet",
},
{
Name: "Manticore", Power: 6, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentBoost, Count: 1}},
EffectText: "Faint: set aside — Ailments on enemy pets count for 1 more",
},
{
Name: "Team Spirit", Power: 4, Suits: []Suit{SuitYellow, SuitBlue},
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionManaFeedOnPlay, Count: 2}},
EffectText: "Faint: set aside — each pet you play, spend 1 Mana to feed it 2 Apples",
},
{
Name: "Behemoth", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Count: 2}},
EffectText: "After Attacking: if it hasn't fainted, eat 2 Apples",
},
{
Name: "Quetzalcoatl", Power: 6, Suits: []Suit{SuitRed, SuitBlue},
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionRevealTierForApples, Count: 3, Cap: 3}},
EffectText: "Buy: reveal a tier 3 or lower pet from your hand to gain 3 Apples",
},
},
}
// unicornFoodTiers defines the Unicorn pack's food cards (tiers 1-3 printed).
var unicornFoodTiers = [MaxRounds][]foodTemplate{
{}, // Tier 1
{ // Tier 2
{
Name: "Fairy Dust", Food: FoodFairyDust, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}},
EffectText: "Play: gain 1 Mana",
},
},
{ // Tier 3
{
Name: "Water of Youth", Food: FoodWaterOfYouth, Copies: 2,
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionUpgradeNextTier}},
EffectText: "Buy: discard this and a pet to buy the top of the next tier for free",
},
},
{ // Tier 4
{
Name: "Health Potion", Food: FoodHealthPotion, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionHeal, Count: 3, SurviveOnly: true}},
EffectText: "Hurt: if this pet hasn't fainted, heal 3 damage",
},
},
{ // Tier 5
{
Name: "Big Mana Potion", Food: FoodBigManaPotion, Copies: 2, Perk: true,
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Count: 3}},
EffectText: "Play: gain 3 Mana",
},
},
{ // Tier 6
{
Name: "Cornucopia", Food: FoodCornucopia, Copies: 2, Perk: true,
Effects: []Effect{
{Trigger: TriggerPlay, Action: ActionEatApple, Count: 2},
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 2},
},
EffectText: "Play: eat 2 Apples and add 2 Apples on top of your deck",
},
},
}
// newCardID mints a unique card ID within the game.
func (g *Game) newCardID() string {
g.NextCardID++
@@ -866,6 +1284,8 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla
switch pack {
case "golden":
return &goldenPetTiers, &goldenFoodTiers
case "unicorn":
return &unicornPetTiers, &unicornFoodTiers
default: // turtle (and the placeholder packs, until they ship)
return &petTiers, &foodTiers
}
@@ -981,6 +1401,23 @@ func (g *Game) newApple() Card {
}
}
// newAilment mints an ailment debuff card (Unicorn pack). It is temporary — it
// leaves play at the end of the round, like an apple — and belongs to no deck
// until an effect attaches it to a pet or drops it on a deck.
func (g *Game) newAilment(kind string) Card {
name := "Spooked"
if kind == AilmentExposed {
name = "Exposed"
}
return Card{
ID: g.newCardID(),
Kind: KindAilment,
Name: name,
Ailment: kind,
Temporary: true,
}
}
// newBee mints a bee: a temporary 1-power pet with no effect, summoned by
// other pets' effects. It counts as a pet while it exists (foods can attach
// to it in battle).
+187 -2
View File
@@ -66,6 +66,19 @@ type Player struct {
// PendingTrumpets (Golden pack: Bird of Paradise) is Trumpets the next
// battle starts with in the pool; reset after that battle.
PendingTrumpets int `json:"pendingTrumpets,omitempty"`
// Mana (Unicorn pack) is a persistent resource, added to the play area as a
// counter. It is gained shop-time (Cuddle Toad, Thunderbird) and battle-time
// (Alchemedes, Fur-Bearing Trout), spent to power abilities (see
// Effect.CostMana), and — unlike Trumpets — kept between rounds.
Mana int `json:"mana,omitempty"`
// NextRoundApples (Unicorn pack: Skeleton Dog) is apples banked in battle
// that land in the player's hand at the start of the next round, then reset.
NextRoundApples int `json:"nextRoundApples,omitempty"`
// ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used
// Bigfoot's reveal (once per round); ShopPeek is the card they saw — a
// snapshot of the shop deck's top, shown only in that player's own view.
ShopPeekedRound int `json:"shopPeekedRound,omitempty"`
ShopPeek *Card `json:"shopPeek,omitempty"`
// IsBot marks a computer-controlled seat. The engine treats bots exactly
// like humans; the server drives their actions. BotLevel is the bot's
// skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the
@@ -105,6 +118,20 @@ type PendingReveal struct {
PlayerID string `json:"playerId"`
Source string `json:"source"` // the Cockatoo's card id
Options []string `json:"options"` // eligible pet card ids in the buyer's deck
// Apples, when > 0, is a fixed apple reward (Unicorn pack: Quetzalcoatl gives
// 3). When 0 the reward is the revealed pet's Power (Golden pack: Cockatoo).
Apples int `json:"apples,omitempty"`
}
// PendingSacrifice is an in-progress Water of Youth buy (Unicorn pack): the
// buyer must discard one of their pets; that pet and the Water of Youth food
// are consumed to take the top card of the next tier's deck for free. Tier is
// the tier the reward comes from.
type PendingSacrifice struct {
PlayerID string `json:"playerId"`
Source string `json:"source"` // the Water of Youth card id (also discarded)
Tier int `json:"tier"` // 1-based tier the reward is drawn from
Options []string `json:"options"` // eligible pet card ids to sacrifice
}
// Game is the complete authoritative state. It is a pure state machine: no
@@ -120,6 +147,10 @@ type Game struct {
Players []*Player `json:"players"`
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
ShopRow []Card `json:"shopRow"` // empty ID = empty slot
// Discards (Unicorn pack) is the pile of real cards that came from the shop
// decks and later left a player's deck (sold, traded, sacrificed), keyed by
// tier. Chimera and Abomination draw from it. Temporary cards never enter.
Discards map[int][]Card `json:"discards,omitempty"`
Turn int `json:"turn"` // seat with the current shop turn
// PrioritySeat holds the priority token: that seat shops first each round
// and wins simultaneity races in battle. Assigned randomly at game start;
@@ -130,7 +161,10 @@ type Game struct {
// PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks
// other shop actions on that seat until resolved, like Pending.
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
// PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack);
// it blocks other shop actions on that seat until resolved, like Pending.
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
NextCardID int `json:"nextCardId"`
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
// Log is the running, human-readable event log shown across every phase.
@@ -350,12 +384,22 @@ func (g *Game) startShopRound() {
g.Phase = PhaseShop
g.Pending = nil
g.PendingReveal = nil
g.PendingSacrifice = nil
for _, p := range g.Players {
p.Coins = CoinsPerRound
p.Ready = false
p.TripledThisRound = false
p.FirstBuyFree = false
p.BuysThisRound = 0
p.ShopPeek = nil
// Unicorn pack: apples banked in battle (Skeleton Dog) arrive in hand now.
for range p.NextRoundApples {
p.Deck = append(p.Deck, g.newApple())
}
if p.NextRoundApples > 0 {
g.logf(p.Seat, "🍎", "%s starts the round with %d banked apple%s.", p.Name, p.NextRoundApples, plural(p.NextRoundApples))
p.NextRoundApples = 0
}
}
g.ShopRow = make([]Card, ShopRowSize)
for i := range g.ShopRow {
@@ -402,6 +446,9 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) {
if g.PendingReveal != nil {
return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction)
}
if g.PendingSacrifice != nil {
return nil, fmt.Errorf("%w: finish your choice first", ErrInvalidAction)
}
return p, nil
}
@@ -459,7 +506,7 @@ func (g *Game) BuyAvocado(playerID string, rowIdx int) error {
// advanceAfterBuy hands off the turn after a buy, unless the buy opened a
// Cockatoo reveal that the same player must resolve first.
func (g *Game) advanceAfterBuy() {
if g.PendingReveal != nil {
if g.PendingReveal != nil || g.PendingSacrifice != nil {
return
}
g.advanceShopTurn()
@@ -552,6 +599,22 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
if len(opts) > 0 {
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts}
}
case ActionRevealTierForApples:
// Unicorn pack: Quetzalcoatl — reveal a hand pet at tier <= Cap for a
// fixed number of apples.
if g.Phase != PhaseShop {
continue
}
maxTier := e.Cap
var opts []string
for _, dc := range p.Deck {
if dc.IsPet() && dc.ID != c.ID && (maxTier <= 0 || dc.Tier <= maxTier) {
opts = append(opts, dc.ID)
}
}
if len(opts) > 0 {
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts, Apples: e.count()}
}
case ActionApplesInPlay:
// Golden pack: apples-in-play banked when sold (Hercules Beetle) or
// bought (Bird of Paradise). Monkey's battle-prep version is resolved
@@ -612,6 +675,33 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: apples,
Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)})
}
case ActionGainMana:
// Unicorn pack: shop-time mana (Cuddle Toad, Thunderbird). Battle-time
// mana is handled by the resolver; skip it here.
if trigger == TriggerBuy || trigger == TriggerSell || trigger == TriggerTriple {
p.Mana += e.count()
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔮", Kind: LogMana, Source: c.ID, Count: e.count(),
Text: fmt.Sprintf("%s gains %d Mana (now %d).", c.Name, e.count(), p.Mana)})
}
case ActionUpgradeNextTier:
// Unicorn pack: Water of Youth — sacrifice a pet + this food to buy the
// top of the next tier for free. Only a real shop buy opens the choice.
if trigger != TriggerBuy || g.Phase != PhaseShop {
continue
}
nextTier := g.Round + 1
if nextTier > MaxRounds || len(g.ShopDecks[nextTier-1]) == 0 {
continue // no higher tier to upgrade into; the food is wasted
}
var opts []string
for _, dc := range p.Deck {
if dc.IsPet() {
opts = append(opts, dc.ID)
}
}
if len(opts) > 0 {
g.PendingSacrifice = &PendingSacrifice{PlayerID: p.ID, Source: c.ID, Tier: nextTier, Options: opts}
}
}
}
}
@@ -636,6 +726,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error {
}
for _, c := range sold {
p.Deck = append(p.Deck, g.newApple())
g.discardCard(c)
g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Kind: LogSell, Source: c.ID, CardName: c.Name, Spawn: "apple",
Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)})
g.applyShopTrigger(p, c, TriggerSell)
@@ -711,6 +802,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
for _, id := range cardIDs {
idx := p.cardIndex(id)
traded = append(traded, p.Deck[idx])
g.discardCard(p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
p.TripledThisRound = true
@@ -785,6 +877,9 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
}
revealed := p.Deck[idx]
n := revealed.Power
if g.PendingReveal.Apples > 0 {
n = g.PendingReveal.Apples // Quetzalcoatl: fixed reward
}
for range n {
p.Deck = append(p.Deck, g.newApple())
}
@@ -797,6 +892,83 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
return nil
}
// PeekShopDeck resolves Bigfoot's optional reveal (Unicorn pack): on the
// player's shop turn, once per round, they look at the top card of the current
// tier's shop deck. It doesn't spend gold or end the turn — it only stores a
// private snapshot served in that player's own view.
func (g *Game) PeekShopDeck(playerID string) error {
p, err := g.requireShopTurn(playerID)
if err != nil {
return err
}
if p.ShopPeekedRound == g.Round {
return fmt.Errorf("%w: already peeked this round", ErrInvalidAction)
}
hasBigfoot := false
for _, c := range p.Deck {
for _, e := range c.Effects {
if e.Action == ActionPeekShop {
hasBigfoot = true
}
}
}
if !hasBigfoot {
return fmt.Errorf("%w: no pet can peek the shop deck", ErrInvalidAction)
}
p.ShopPeekedRound = g.Round
deck := g.ShopDecks[g.Round-1]
if len(deck) > 0 {
top := deck[0]
p.ShopPeek = &top
}
g.logf(p.Seat, "👁", "%s reveals a pet to peek at the shop deck.", p.Name)
return nil
}
// SacrificeChoose resolves a pending Water of Youth choice (Unicorn pack): the
// chosen pet and the Water of Youth food are discarded, and the top of the next
// tier's deck joins the player's deck for free (its Buy effect fires).
func (g *Game) SacrificeChoose(playerID, cardID string) error {
if g.Phase != PhaseShop || g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != playerID {
return fmt.Errorf("%w: no choice waiting on you", ErrInvalidAction)
}
if !slices.Contains(g.PendingSacrifice.Options, cardID) {
return fmt.Errorf("%w: choose one of your pets", ErrInvalidAction)
}
p := g.PlayerByID(playerID)
ps := g.PendingSacrifice
g.PendingSacrifice = nil
// Discard the sacrificed pet and the Water of Youth food.
var sacrificed Card
if idx := p.cardIndex(cardID); idx >= 0 {
sacrificed = p.Deck[idx]
g.discardCard(sacrificed)
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
if idx := p.cardIndex(ps.Source); idx >= 0 {
g.discardCard(p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
top := g.drawFromTier(ps.Tier)
if top.ID == "" {
// Tier emptied out between the buy and the choice; nothing to grant.
g.logf(p.Seat, "⏳", "%s's Water of Youth fizzles — the next tier is empty.", p.Name)
g.advanceShopTurn()
return nil
}
p.Deck = append(p.Deck, top)
if hasBuyEffect(top) {
g.addLog(LogEntry{Seat: p.Seat, Icon: "⏳", Kind: LogBuy, Source: top.ID, CardName: top.Name,
Text: fmt.Sprintf("%s trades %s for %s %s from tier %d — its buy ability triggers.",
p.Name, sacrificed.Name, article(top.Name), top.Name, ps.Tier)})
} else {
g.logf(p.Seat, "⏳", "%s trades %s for a fresh tier %d pet.", p.Name, sacrificed.Name, ps.Tier)
}
g.applyShopTrigger(p, top, TriggerBuy)
g.advanceAfterBuy()
return nil
}
// DebugGrant drops any card straight into a player's deck during the shop,
// free and off-turn — a testing aid gated behind the server's DEBUG flag, not
// a normal action. No buy effects fire.
@@ -957,6 +1129,19 @@ func (g *Game) finish() {
}
}
// discardCard records a real (non-temporary) card leaving a player's deck into
// the tier's discard pile (Unicorn pack: Chimera / Abomination source). Apples,
// bees, and ailments never enter the pile.
func (g *Game) discardCard(c Card) {
if c.Temporary || c.Tier <= 0 {
return
}
if g.Discards == nil {
g.Discards = map[int][]Card{}
}
g.Discards[c.Tier] = append(g.Discards[c.Tier], c)
}
func hasDuplicates(ids []string) bool {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
+3
View File
@@ -34,6 +34,9 @@ const (
LogSell = "sell" // Seat sold Source/CardName (it became an apple)
LogTrade = "trade" // Seat traded in Cards for a next-tier pick
LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed
// LogMana (Unicorn pack) tags a public shop-time Mana gain (Cuddle Toad,
// Thunderbird), so observers can track the acting seat's Mana pool.
LogMana = "mana"
)
// addLog appends an entry, stamping it with the next sequence number and the
+2 -4
View File
@@ -1,9 +1,7 @@
package game
// Card packs are the selectable sets of pets and food a game is played with.
// Turtle and Golden ship with full card data; Unicorn is declared here as
// infrastructure (shown but not yet playable) so the lobby, views, and
// deck-building all have a single source of truth to grow into.
// Turtle, Golden, and Unicorn all ship with full six-tier card data.
// PackInfo describes one selectable pack. Playable gates whether a lobby may
// choose it and start a game with it.
@@ -21,7 +19,7 @@ const DefaultPack = "turtle"
var Packs = []PackInfo{
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
{ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: true},
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false},
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: true},
}
// packByID looks up a pack, returning false if the id is unknown.
+939
View File
@@ -0,0 +1,939 @@
package game
import "testing"
// --- Unicorn pack test helpers ---
// unicornGame builds a started 2-player game on the Unicorn pack, bypassing the
// lobby and the Playable gate (tiers 4-6 aren't printed yet).
func unicornGame(t *testing.T) (*Game, *Player, *Player) {
t.Helper()
g := New()
g.Pack = "unicorn"
g.buildDecks()
p1, err := g.AddPlayer("Alice")
if err != nil {
t.Fatal(err)
}
p2, err := g.AddPlayer("Bob")
if err != nil {
t.Fatal(err)
}
g.start()
g.PrioritySeat = p1.Seat
g.Turn = p1.Seat
return g, p1, p2
}
// unicornPet mints a copy of a Unicorn pack pet (with effects) by name.
func (g *Game) unicornPet(t *testing.T, name string) Card {
t.Helper()
for tierIdx, tier := range unicornPetTiers {
for _, tmpl := range tier {
if tmpl.Name == name {
return Card{
ID: g.newCardID(), Kind: KindPet, Name: tmpl.Name, Tier: tierIdx + 1,
Power: tmpl.Power, Suit: tmpl.Suits[0],
Effects: tmpl.Effects, EffectText: tmpl.EffectText,
}
}
}
}
t.Fatalf("no unicorn pet named %s", name)
return Card{}
}
// unicornFood mints a Unicorn pack food by name.
func (g *Game) unicornFood(t *testing.T, name string) Card {
t.Helper()
for tierIdx, tier := range unicornFoodTiers {
for _, f := range tier {
if f.Name == name {
return Card{
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
}
}
}
}
t.Fatalf("no unicorn food named %s", name)
return Card{}
}
func manaEvents(res *BattleResult) []BattleEvent { return eventsOfType(res, "mana") }
// --- Mana ---
// Cuddle Toad's Buy grants persistent Mana that survives into later rounds.
func TestCuddleToadShopMana(t *testing.T) {
g, p1, _ := unicornGame(t)
g.ShopRow[0] = g.unicornPet(t, "Cuddle Toad")
if err := g.Buy(p1.ID, 0); err != nil {
t.Fatal(err)
}
if p1.Mana != 1 {
t.Fatalf("Cuddle Toad should grant 1 Mana on buy, got %d", p1.Mana)
}
}
// Thunderbird grants 2 Mana on buy.
func TestThunderbirdShopMana(t *testing.T) {
g, p1, _ := unicornGame(t)
g.ShopRow[0] = g.unicornPet(t, "Thunderbird")
if err := g.Buy(p1.ID, 0); err != nil {
t.Fatal(err)
}
if p1.Mana != 2 {
t.Fatalf("Thunderbird should grant 2 Mana, got %d", p1.Mana)
}
}
// Alchemedes gains Mana on play, and it persists on the player after the battle.
func TestAlchemedesBattleManaPersists(t *testing.T) {
g, p1, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Alchemedes"), g.pet("Body", 3)},
[]Card{g.pet("Weak", 1)},
)
gains := 0
for _, ev := range manaEvents(res) {
if ev.Seat == 0 && ev.Count > 0 {
gains += ev.Count
}
}
if gains != 1 {
t.Fatalf("Alchemedes should gain 1 Mana in battle, got %d", gains)
}
if p1.Mana != 1 {
t.Fatalf("Mana should persist on the player after battle, got %d", p1.Mana)
}
}
// Pengobble spends banked Mana to throw 2 Rocks; with no Mana it does nothing.
func TestPengobbleSpendsMana(t *testing.T) {
g, p1, _ := unicornGame(t)
g.RollDie = func() int { return 2 } // each rock deals 2
p1.Mana = 1
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Pengobble")},
[]Card{g.pet("Tank", 3)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
t.Fatalf("Pengobble should spend Mana to throw 2 rocks (4 dmg, kills 3-tank): %+v", rocks)
}
if p1.Mana != 0 {
t.Fatalf("Pengobble should have spent the Mana, left %d", p1.Mana)
}
}
func TestPengobbleNoManaNoRocks(t *testing.T) {
g, _, _ := unicornGame(t)
g.RollDie = func() int { return 2 }
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Pengobble")},
[]Card{g.pet("Tank", 3)},
)
if rocks := eventsOfType(res, "rock"); len(rocks) != 0 {
t.Fatalf("Pengobble with no Mana should throw no rocks, got %+v", rocks)
}
}
// Tatzelwurm gains 1 Mana per blank rolled while still dealing rock damage.
func TestTatzelwurmManaOnBlanks(t *testing.T) {
g, p1, _ := unicornGame(t)
g.RollDie = func() int { return 0 } // both rocks blank
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Tatzelwurm"), g.pet("Body", 3)},
[]Card{g.pet("Tank", 3)},
)
gains := 0
for _, ev := range manaEvents(res) {
if ev.Seat == 0 && ev.Count > 0 {
gains += ev.Count
}
}
if gains != 2 {
t.Fatalf("Tatzelwurm should gain 2 Mana from 2 blanks, got %d", gains)
}
if p1.Mana != 2 {
t.Fatalf("Tatzelwurm Mana should persist, got %d", p1.Mana)
}
}
// Gargoyle spends Mana on faint to bank 2 apples on top of its deck.
func TestGargoyleFaintSpendsMana(t *testing.T) {
g, _, _ := unicornGame(t)
g.Players[0].Mana = 1
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Gargoyle")},
[]Card{g.pet("Killer", 5)},
)
apples := 0
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("Gargoyle should summon 2 apples when it can pay Mana, got %d", apples)
}
}
// Fairy Dust (perk) grants its pet 1 Mana when the pet is played in battle.
func TestFairyDustPerkMana(t *testing.T) {
g, p1, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornFood(t, "Fairy Dust"), g.pet("Body", 4)},
[]Card{g.pet("Weak", 1)},
)
gains := 0
for _, ev := range manaEvents(res) {
if ev.Seat == 0 && ev.Count > 0 {
gains += ev.Count
}
}
if gains != 1 {
t.Fatalf("Fairy Dust should grant 1 Mana on play, got %d", gains)
}
if p1.Mana != 1 {
t.Fatalf("Fairy Dust Mana should persist, got %d", p1.Mana)
}
}
// --- Ailments ---
// Basilisk's Exposed makes the enemy pet take extra damage on each hit — a
// 1-power Basilisk trades into a 2-power pet it could never otherwise kill.
func TestBasiliskExposedKills(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Basilisk"), g.pet("Cleanup", 2)},
[]Card{g.pet("Two", 2)},
)
if len(eventsOfType(res, "ailment")) == 0 {
t.Fatal("expected an ailment event from Basilisk")
}
// Basilisk (1) + 1 Exposed deals 2 to Two (2) — it faints in the clash; the
// cleanup pet then wins for seat 0.
if res.WinnerSeat != 0 {
t.Fatalf("Exposed should let Basilisk's side win, got winner %d", res.WinnerSeat)
}
}
// Nightcrawler's 2 Spooked cut the enemy pet's clash attack.
func TestNightcrawlerSpookedReducesAttack(t *testing.T) {
g, _, _ := unicornGame(t)
// Seat 0: Nightcrawler(1) spooks the enemy by 2, then a 3-power body.
// Seat 1: a 3-power attacker. Spooked drops its attack to 1, so the body
// survives and seat 0 wins.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Nightcrawler"), g.pet("Body", 3)},
[]Card{g.pet("Bruiser", 3)},
)
if res.WinnerSeat != 0 {
t.Fatalf("Spooked should weaken the Bruiser enough for seat 0 to win, got %d", res.WinnerSeat)
}
}
// Baku shrugs off the first ailment it would receive.
func TestBakuDiscardsFirstAilment(t *testing.T) {
g, _, _ := unicornGame(t)
// Seat 1 leads with Barghest (spooks Baku by 1) — Baku's guard eats it, so
// Baku attacks at full power.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Baku")},
[]Card{g.unicornPet(t, "Barghest"), g.pet("Body", 2)},
)
// Barghest (1) spooks Baku, but the guard cancels it. Baku(2) vs Barghest(1):
// Baku deals 2, Barghest dies; Baku takes 1, survives. Then Baku(2, 1 dmg)
// vs Body(2): Baku deals 2 → Body dies; Body deals 2 → Baku total 3 ≥ 2 dies.
// Both empty ⇒ draw. Without the guard Baku would deal only 1 and lose. So a
// non-loss for seat 0 proves the guard worked.
if res.WinnerSeat == 1 {
t.Fatal("Baku should not lose — its guard cancels Barghest's Spooked")
}
ail := eventsOfType(res, "ailment")
if len(ail) != 1 || ail[0].Count != 0 {
t.Fatalf("Baku's guard should record a shrugged-off ailment (count 0), got %+v", ail)
}
}
// Frost Wolf's faint exposes the enemy pet and drops another Exposed on top of
// the enemy deck for the next pet.
func TestFrostWolfExposes(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Frost Wolf")},
[]Card{g.pet("A", 2), g.pet("B", 2)},
)
// One Exposed on the current enemy pet (an ailment event) and one on top of
// the enemy deck (a summon of an ailment card onto seat 1).
if len(eventsOfType(res, "ailment")) == 0 {
t.Fatal("Frost Wolf should afflict the enemy pet in play")
}
deckAilment := false
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
deckAilment = true
}
}
if !deckAilment {
t.Fatal("Frost Wolf should drop an Exposed on top of the enemy deck")
}
}
// Mothman eats 2 apples only when the enemy pet already carries an ailment.
func TestMothmanEatsOnEnemyAilment(t *testing.T) {
g, _, _ := unicornGame(t)
// Seat 1 leads with Basilisk, which exposes seat 0's first pet (Mothman).
// Wait — Mothman checks the ENEMY's ailment. Put the ailment on the enemy:
// seat 0 = [Barghest (spooks enemy), Mothman]; when Mothman enters, the enemy
// pet is spooked, so Mothman eats.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Barghest"), g.unicornPet(t, "Mothman"), g.pet("Body", 5)},
[]Card{g.pet("Tank", 8)},
)
ate := false
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 {
ate = true
}
}
if !ate {
t.Fatal("Mothman should eat when the enemy pet is afflicted")
}
}
func TestMothmanNoEatWithoutAilment(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Mothman"), g.pet("Body", 5)},
[]Card{g.pet("Tank", 8)},
)
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 {
t.Fatal("Mothman should not eat when the enemy pet has no ailment")
}
}
}
// Calygreyhound spends Mana to eat an apple and spook the enemy.
func TestCalygreyhoundSpendsMana(t *testing.T) {
g, _, _ := unicornGame(t)
g.Players[0].Mana = 1
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Calygreyhound"), g.pet("Body", 3)},
[]Card{g.pet("Tank", 6)},
)
if len(eventsOfType(res, "ailment")) == 0 {
t.Fatal("Calygreyhound should spook the enemy when it can pay Mana")
}
ate := false
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 {
ate = true
}
}
if !ate {
t.Fatal("Calygreyhound should also eat an apple")
}
if g.Players[0].Mana != 0 {
t.Fatalf("Calygreyhound should have spent the Mana, left %d", g.Players[0].Mana)
}
}
// --- Faint mechanics ---
// Fur-Bearing Trout gains Mana and banks 2 apples when it faints.
func TestFurBearingTroutFaint(t *testing.T) {
g, p1, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Fur-Bearing Trout")},
[]Card{g.pet("Killer", 5)},
)
if p1.Mana != 1 {
t.Fatalf("Fur-Bearing Trout should bank 1 Mana on faint, got %d", p1.Mana)
}
apples := 0
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
apples++
}
}
if apples != 2 {
t.Fatalf("Fur-Bearing Trout should summon 2 apples on faint, got %d", apples)
}
}
// Skeleton Dog banks an apple for the next round's hand.
func TestSkeletonDogBanksNextRoundApple(t *testing.T) {
g, p1, _ := unicornGame(t)
forceBattle(t, g,
[]Card{g.unicornPet(t, "Skeleton Dog")},
[]Card{g.pet("Killer", 5)},
)
if p1.NextRoundApples != 1 {
t.Fatalf("Skeleton Dog should bank 1 next-round apple, got %d", p1.NextRoundApples)
}
// Acknowledge the battle and enter the next round; the apple should land.
before := countFood(p1.Deck, FoodApple)
g.AcknowledgeBattle(p1.ID)
g.AcknowledgeBattle(g.Players[1].ID)
if g.Phase != PhaseShop {
t.Fatalf("expected a new shop round, got %s", g.Phase)
}
after := countFood(p1.Deck, FoodApple)
if after != before+1 {
t.Fatalf("banked apple should arrive next round: before=%d after=%d", before, after)
}
if p1.NextRoundApples != 0 {
t.Fatalf("the bank should reset after paying out, got %d", p1.NextRoundApples)
}
}
func countFood(deck []Card, food string) int {
n := 0
for _, c := range deck {
if c.Food == food {
n++
}
}
return n
}
// Mandrake cancels the next enemy Faint ability. Mandrake must faint first to
// arm the negation, so seat 0's Heavy kills it, then seat 0's Ant later faints
// against seat 1's Big — and its apple is cancelled.
func TestMandrakeNegatesEnemyFaint(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Heavy", 3), g.tier1(t, "Ant")},
[]Card{g.unicornPet(t, "Mandrake"), g.pet("Big", 6)},
)
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
t.Fatal("Ant's faint ability should have been cancelled by Mandrake")
}
}
// Sanity: without the negation the Ant's apple would appear — confirm the
// Mandrake actually got set aside and released as a cancel.
sawCancel := false
for _, ev := range eventsOfType(res, "release") {
if ev.Card != nil && ev.Card.Name == "Mandrake" {
sawCancel = true
}
}
if !sawCancel {
t.Fatal("expected the Mandrake to spend its negation on Ant's faint")
}
}
// Slime revives itself once and drops an Exposed on the enemy deck.
func TestSlimeReviveOnce(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Slime")},
[]Card{g.pet("A", 2), g.pet("B", 2), g.pet("C", 2)},
)
revives := 0
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == "Slime" {
revives++
}
}
if revives != 1 {
t.Fatalf("Slime should revive exactly once, got %d", revives)
}
deckAilment := false
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
deckAilment = true
}
}
if !deckAilment {
t.Fatal("Slime should drop an Exposed on the enemy deck")
}
}
// --- Shop foods & abilities ---
// Lucky Cat's Triple adds 3 apples to the hand.
func TestLuckyCatTriple(t *testing.T) {
g, p1, _ := unicornGame(t)
// Three same-suit pets including Lucky Cat, so the Triple is legal.
lc := g.unicornPet(t, "Lucky Cat") // red
a := g.suitedPet("A", 1, SuitRed)
b := g.suitedPet("B", 1, SuitRed)
p1.Deck = []Card{lc, a, b}
before := countFood(p1.Deck, FoodApple)
if err := g.TradeStart(p1.ID, []string{lc.ID, a.ID, b.ID}); err != nil {
t.Fatal(err)
}
after := countFood(p1.Deck, FoodApple)
if after != before+3 {
t.Fatalf("Lucky Cat Triple should add 3 apples: before=%d after=%d", before, after)
}
}
// Water of Youth: buying it opens a sacrifice choice; resolving it discards the
// chosen pet and the food, and grants a next-tier pet for free.
func TestWaterOfYouthUpgrade(t *testing.T) {
g, p1, _ := unicornGame(t)
junk := g.suitedPet("Junk", 1, SuitRed)
p1.Deck = []Card{junk}
// Pin the top of the next tier's deck to a known pet with a Buy effect.
g.ShopDecks[1] = append([]Card{g.unicornPet(t, "Thunderbird")}, g.ShopDecks[1]...)
g.ShopRow[0] = g.unicornFood(t, "Water of Youth")
if err := g.Buy(p1.ID, 0); err != nil {
t.Fatal(err)
}
if g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != p1.ID {
t.Fatal("buying Water of Youth should open a sacrifice choice")
}
if err := g.SacrificeChoose(p1.ID, junk.ID); err != nil {
t.Fatal(err)
}
if p1.cardIndex(junk.ID) >= 0 {
t.Fatal("the sacrificed pet should be gone")
}
for _, c := range p1.Deck {
if c.Food == FoodWaterOfYouth {
t.Fatal("Water of Youth food should be consumed")
}
}
gotUpgrade := false
for _, c := range p1.Deck {
if c.IsPet() && c.Name == "Thunderbird" {
gotUpgrade = true
}
}
if !gotUpgrade {
t.Fatalf("expected the Thunderbird upgrade from the next tier, deck=%+v", p1.Deck)
}
// The granted pet's Buy effect fires: Thunderbird grants 2 Mana.
if p1.Mana != 2 {
t.Fatalf("upgrade's Buy effect should fire (Thunderbird → 2 Mana), got %d", p1.Mana)
}
}
// Bigfoot lets its owner peek at the top of the shop deck, once per round.
func TestBigfootPeek(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Deck = []Card{g.unicornPet(t, "Bigfoot")}
if err := g.PeekShopDeck(p1.ID); err != nil {
t.Fatal(err)
}
if p1.ShopPeek == nil {
t.Fatal("peeking should reveal the top of the shop deck")
}
top := g.ShopDecks[g.Round-1][0]
if p1.ShopPeek.ID != top.ID {
t.Fatalf("peek should show the deck's top card %s, got %s", top.Name, p1.ShopPeek.Name)
}
if err := g.PeekShopDeck(p1.ID); err == nil {
t.Fatal("Bigfoot should only peek once per round")
}
}
func TestPeekRequiresBigfoot(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Deck = []Card{g.pet("Plain", 2)}
if err := g.PeekShopDeck(p1.ID); err == nil {
t.Fatal("peeking without a Bigfoot should be rejected")
}
}
// --- Tier 4-6 ---
func seat0ManaGains(res *BattleResult) int {
n := 0
for _, ev := range manaEvents(res) {
if ev.Seat == 0 && ev.Count > 0 {
n += ev.Count
}
}
return n
}
func seat0Summons(res *BattleResult, name string) int {
n := 0
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == name {
n++
}
}
return n
}
// Roc grants Mana on both Play (battle) and Sell (shop).
func TestRocMana(t *testing.T) {
g, p1, _ := unicornGame(t)
// Sell grants shop-time mana.
roc := g.unicornPet(t, "Roc")
p1.Deck = []Card{roc, g.pet("Keep", 2)}
if err := g.Sell(p1.ID, []string{roc.ID}); err != nil {
t.Fatal(err)
}
if p1.Mana != 1 {
t.Fatalf("Roc Sell should grant 1 Mana, got %d", p1.Mana)
}
// Play grants battle-time mana too.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Roc")},
[]Card{g.pet("Weak", 1)},
)
if seat0ManaGains(res) != 1 {
t.Fatalf("Roc Play should grant 1 Mana, got %d", seat0ManaGains(res))
}
}
// Kraken spooks the enemy pet and drops a Spooked on the enemy deck.
func TestKrakenSpooks(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Kraken")},
[]Card{g.pet("A", 3), g.pet("B", 3)},
)
if len(eventsOfType(res, "ailment")) == 0 {
t.Fatal("Kraken should spook the enemy pet")
}
deckAilment := false
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
deckAilment = true
}
}
if !deckAilment {
t.Fatal("Kraken should drop a Spooked on the enemy deck")
}
}
// Unicorn's guard converts the next incoming friendly ailment into 2 apples.
func TestUnicornAilmentGuard(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Unicorn"), g.pet("Tank", 10)},
[]Card{g.pet("Sword", 4), g.unicornPet(t, "Nightcrawler")},
)
// Unicorn(4) trades with Sword(4) and faints, arming the guard. Nightcrawler
// then tries to add 2 Spooked to the Tank; the guard turns the first into 2
// apples on our deck (a release + apple summons), the second lands.
released := false
for _, ev := range eventsOfType(res, "release") {
if ev.Card != nil && ev.Card.Name == "Unicorn" {
released = true
}
}
if !released {
t.Fatal("Unicorn's guard should fire against the incoming Spooked")
}
if seat0Summons(res, "Apple") < 2 {
t.Fatalf("the guard should add 2 apples on our deck, got %d", seat0Summons(res, "Apple"))
}
}
// Rootlin buffs friendly pets with base Power 2 or less.
func TestRootlinSmallPetAura(t *testing.T) {
g, _, _ := unicornGame(t)
// Rootlin faints to a 7-power Killer, arming +1 for small pets. A 2-power
// Small then hits at 3, exactly killing the wounded Killer — a mutual KO
// (draw). Without the aura the Small would deal 2 and lose outright.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Rootlin"), g.pet("Small", 2)},
[]Card{g.pet("Killer", 7)},
)
if res.WinnerSeat == 1 {
t.Fatal("Rootlin's aura should let the Small pet trade with the Killer (draw, not a loss)")
}
}
// Fairy recycles the next friendly faint to the bottom of the deck.
func TestFairyRecyclesNextFaint(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Mana = 3
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Fairy"), g.tier1(t, "Ant")},
[]Card{g.pet("Killer", 5)},
)
// Fairy(1) dies to Killer, spends 3 Mana, arms the guard. Ant then dies and
// is sent to the bottom of our deck as a fresh copy.
recycled := false
for _, ev := range eventsOfType(res, "summon") {
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == "Ant" {
recycled = true
}
}
if !recycled {
t.Fatal("Fairy should recycle the Ant to the bottom of the deck")
}
if p1.Mana != 0 {
t.Fatalf("Fairy should have spent 3 Mana, left %d", p1.Mana)
}
}
// Health Potion heals damage when its pet survives a hit.
func TestHealthPotionHeals(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornFood(t, "Health Potion"), g.pet("Big", 10)},
[]Card{g.pet("Chip", 3)},
)
if len(eventsOfType(res, "heal")) == 0 {
t.Fatal("Health Potion should heal the pet after it is hurt")
}
if res.WinnerSeat != 0 {
t.Fatalf("the healed 10-power pet should win, got %d", res.WinnerSeat)
}
}
// Kitsune gains Mana per friendly fainted pet at play time.
func TestKitsuneManaPerFaint(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.pet("Weak", 1), g.unicornPet(t, "Kitsune"), g.pet("Big", 5)},
[]Card{g.pet("Mid", 3)},
)
// Weak faints (1 friendly fainted), Kitsune enters and gains 1 Mana.
if seat0ManaGains(res) != 1 {
t.Fatalf("Kitsune should gain 1 Mana for the 1 fainted pet, got %d", seat0ManaGains(res))
}
}
// Werewolf eats 6 apples only on even rounds.
func TestWerewolfEvenRound(t *testing.T) {
g, _, _ := unicornGame(t)
g.Round = 2
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Werewolf"), g.pet("Body", 3)},
[]Card{g.pet("Tank", 12)},
)
fed := false
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 && ev.Bonus >= 6 {
fed = true
}
}
if !fed {
t.Fatal("Werewolf should eat 6 apples on an even round")
}
g2, _, _ := unicornGame(t)
g2.Round = 1
res2 := forceBattle(t, g2,
[]Card{g2.unicornPet(t, "Werewolf"), g2.pet("Body", 3)},
[]Card{g2.pet("Tank", 12)},
)
for _, ev := range eventsOfType(res2, "eat") {
if ev.Seat == 0 {
t.Fatal("Werewolf should not eat on an odd round")
}
}
}
// Loveland Frogman bounces the enemy pet to the bottom of the enemy deck.
func TestLovelandBounce(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Loveland Frogman"), g.pet("Body", 5)},
[]Card{g.pet("Enemy", 3), g.pet("Behind", 3)},
)
if len(eventsOfType(res, "bounce")) == 0 {
t.Fatal("Loveland Frogman should bounce the enemy pet")
}
}
// Sleipnir's base Power equals the owner's Mana (capped).
func TestSleipnirManaPower(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Mana = 5
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Sleipnir")},
[]Card{g.pet("Four", 4)},
)
var reveal *BattleEvent
for i, ev := range res.Events {
if ev.Type == "reveal" && ev.Card != nil && ev.Card.Name == "Sleipnir" {
reveal = &res.Events[i]
}
}
if reveal == nil || reveal.Card.Power != 5 {
t.Fatalf("Sleipnir should enter with Power 5 from 5 Mana, got %+v", reveal)
}
if res.WinnerSeat != 0 {
t.Fatalf("the 5-power Sleipnir should beat the 4-power pet, got %d", res.WinnerSeat)
}
}
// Sea Serpent spends all Mana to throw that many Rocks.
func TestSeaSerpentSpendManaRocks(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Mana = 3
g.RollDie = func() int { return 2 }
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Sea Serpent")},
[]Card{g.pet("Tank", 6)},
)
rocks := eventsOfType(res, "rock")
if len(rocks) != 1 || rocks[0].Roll != 6 || !rocks[0].TargetDied {
t.Fatalf("Sea Serpent should spend 3 Mana for 3 rocks (6 dmg): %+v", rocks)
}
if p1.Mana != 0 {
t.Fatalf("Sea Serpent should spend all Mana, left %d", p1.Mana)
}
}
// Bakunawa spends all Mana to spook the enemy pet.
func TestBakunawaSpendManaSpook(t *testing.T) {
g, p1, _ := unicornGame(t)
p1.Mana = 3
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Bakunawa"), g.pet("Body", 3)},
[]Card{g.pet("Bruiser", 4)},
)
spook := 0
for _, ev := range eventsOfType(res, "ailment") {
if ev.Seat == 1 && ev.Card != nil && ev.Card.Ailment == AilmentSpooked {
spook += ev.Count
}
}
if spook != 3 {
t.Fatalf("Bakunawa should add 3 Spooked, got %d", spook)
}
if p1.Mana != 0 {
t.Fatalf("Bakunawa should spend all Mana, left %d", p1.Mana)
}
}
// Manticore boosts the value of Ailments on enemy pets by 1.
func TestManticoreBoostsEnemyAilments(t *testing.T) {
g, _, _ := unicornGame(t)
// Manticore faints to a 12-power BigKiller, arming +1 to enemy ailments.
// Basilisk then exposes the BigKiller: its hit lands for 1 + 1(Exposed) +
// 1(Manticore) = 3, so the wounded BigKiller reaches 6+3 = 9 damage.
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Manticore"), g.unicornPet(t, "Basilisk")},
[]Card{g.pet("BigKiller", 12)},
)
got := 0
for _, ev := range eventsOfType(res, "clash") {
if len(ev.Damage) == 2 {
got = ev.Damage[1]
}
}
if got != 9 {
t.Fatalf("Manticore-boosted Exposed should push BigKiller to 9 damage, got %d", got)
}
}
// Behemoth eats 2 apples after every clash it survives (not once per battle).
func TestBehemothEatsEveryClash(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Behemoth")},
[]Card{g.pet("Chip1", 1), g.pet("Chip2", 1), g.pet("Chip3", 1)},
)
eats := 0
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 {
eats++
}
}
if eats < 2 {
t.Fatalf("Behemoth should eat after each of several clashes, got %d eats", eats)
}
}
// Chimera spends Mana to summon random cards from the tier 1 discard pile.
func TestChimeraSummonFromDiscard(t *testing.T) {
g, _, _ := unicornGame(t)
g.Players[0].Mana = 4
g.Discards = map[int][]Card{1: {g.pet("Discarded", 2)}}
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Chimera")},
[]Card{g.pet("Killer", 5)},
)
if seat0Summons(res, "Discarded") != 2 {
t.Fatalf("Chimera should summon 2 cards from the discard pile, got %d", seat0Summons(res, "Discarded"))
}
if g.Players[0].Mana != 0 {
t.Fatalf("Chimera should spend 4 Mana, left %d", g.Players[0].Mana)
}
}
// Pixiu spends Mana to summon the top of the tier 6 shop deck.
func TestPixiuSummonFromTierDeck(t *testing.T) {
g, _, _ := unicornGame(t)
g.Players[0].Mana = 4
g.ShopDecks[5] = append([]Card{g.pet("SixTop", 6)}, g.ShopDecks[5]...)
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Pixiu")},
[]Card{g.pet("Killer", 5)},
)
if seat0Summons(res, "SixTop") != 1 {
t.Fatalf("Pixiu should summon the top tier 6 card, got %d", seat0Summons(res, "SixTop"))
}
}
// Vampire Bat throws rocks and eats apples equal to the damage dealt, when the
// enemy pet is ailing.
func TestVampireBatRockThenEat(t *testing.T) {
g, _, _ := unicornGame(t)
g.RollDie = func() int { return 2 } // each rock deals 2
res := forceBattle(t, g,
[]Card{g.unicornPet(t, "Barghest"), g.unicornPet(t, "Vampire Bat"), g.pet("Body", 3)},
[]Card{g.pet("Tank", 20)},
)
// Barghest spooks the Tank (an ailment); Vampire Bat then throws 2 rocks (4
// damage) and eats 4 apples.
fed := false
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 && ev.Bonus >= 4 {
fed = true
}
}
if !fed {
t.Fatal("Vampire Bat should eat apples equal to the rock damage dealt")
}
}
// Cornucopia (perk) feeds its pet 2 apples and adds 2 on top of the deck.
func TestCornucopia(t *testing.T) {
g, _, _ := unicornGame(t)
res := forceBattle(t, g,
[]Card{g.unicornFood(t, "Cornucopia"), g.pet("Body", 4)},
[]Card{g.pet("Weak", 1)},
)
fed := false
for _, ev := range eventsOfType(res, "eat") {
if ev.Seat == 0 && ev.Bonus >= 2 {
fed = true
}
}
if !fed {
t.Fatal("Cornucopia should feed its pet 2 apples")
}
if seat0Summons(res, "Apple") < 2 {
t.Fatalf("Cornucopia should add 2 apples on the deck, got %d", seat0Summons(res, "Apple"))
}
}
// Quetzalcoatl reveals a tier 3 or lower pet on buy for 3 apples.
func TestQuetzalcoatlReveal(t *testing.T) {
g, p1, _ := unicornGame(t)
low := g.pet("Low", 2) // tier 1
p1.Deck = []Card{low}
g.ShopRow[0] = g.unicornPet(t, "Quetzalcoatl")
if err := g.Buy(p1.ID, 0); err != nil {
t.Fatal(err)
}
if g.PendingReveal == nil || g.PendingReveal.PlayerID != p1.ID {
t.Fatal("buying Quetzalcoatl should open a reveal")
}
before := countFood(p1.Deck, FoodApple)
if err := g.RevealChoose(p1.ID, low.ID); err != nil {
t.Fatal(err)
}
if got := countFood(p1.Deck, FoodApple) - before; got != 3 {
t.Fatalf("Quetzalcoatl should grant a fixed 3 apples, got %d", got)
}
}
+20 -2
View File
@@ -22,7 +22,13 @@ type PlayerView struct {
BuysThisRound int `json:"buysThisRound,omitempty"`
// Trumpets is the count banked for the next battle (Bird of Paradise).
// Self-only — shown under the player's own deck.
Trumpets int `json:"trumpets,omitempty"`
Trumpets int `json:"trumpets,omitempty"`
// Mana is the player's persistent Mana pool (Unicorn pack). Public: it's a
// play-area counter derivable from public shop/battle events.
Mana int `json:"mana,omitempty"`
// ShopPeek is the card Bigfoot revealed off the top of the shop deck this
// round (Unicorn pack). Self-only — it's private information.
ShopPeek *Card `json:"shopPeek,omitempty"`
Deck []Card `json:"deck,omitempty"` // self only
}
@@ -56,7 +62,10 @@ type View struct {
// see a reveal is in progress; the eligible options are only sent to the
// buyer (they name the buyer's own hidden pets).
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
Battle *BattleResult `json:"battle,omitempty"`
// PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the
// options (the buyer's own pets) are only sent to the buyer.
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
Battle *BattleResult `json:"battle,omitempty"`
WinnerSeat int `json:"winnerSeat"`
// Log is the shared, public event log shown across every phase.
Log []LogEntry `json:"log,omitempty"`
@@ -102,6 +111,7 @@ func (g *Game) ViewFor(playerID string) View {
DeckSize: len(p.Deck),
PetCount: p.PetCount(),
Avocados: p.Avocados,
Mana: p.Mana,
}
if p.ID == playerID {
v.YouSeat = p.Seat
@@ -109,6 +119,7 @@ func (g *Game) ViewFor(playerID string) View {
pv.FirstBuyFree = p.FirstBuyFree
pv.BuysThisRound = p.BuysThisRound
pv.Trumpets = p.PendingTrumpets
pv.ShopPeek = p.ShopPeek
}
v.Players = append(v.Players, pv)
}
@@ -126,6 +137,13 @@ func (g *Game) ViewFor(playerID string) View {
}
v.PendingReveal = &reveal
}
if g.PendingSacrifice != nil {
sac := *g.PendingSacrifice
if sac.PlayerID != playerID {
sac.Options = nil // hide which of the buyer's pets are eligible
}
v.PendingSacrifice = &sac
}
// Battle results (lineups, events) are public once resolved. Keep the
// battle around during the following shop phase too, so late joiners /
// reconnects can still see the last result.