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
+32 -3
View File
@@ -83,9 +83,8 @@ Most trophies after round 6 wins.
## Packs ## Packs
Two card packs are playable, chosen by the host in the lobby (a third, Three card packs are playable, chosen by the host in the lobby. Each pack is
Unicorn, is stubbed for later). Each pack is six tiers, one per round, and six tiers, one per round, and every pet ships as two copies.
every pet ships as two copies.
### Turtle pack ### Turtle pack
@@ -120,6 +119,36 @@ The Golden pack adds mechanics the Turtle pack doesn't have:
- **Cockatoo** — a shop-time reveal, plus Manta Ray's free first buy, - **Cockatoo** — a shop-time reveal, plus Manta Ray's free first buy,
Blue-Ringed Octopus' per-buy apples, and more. Blue-Ringed Octopus' per-buy apples, and more.
### Unicorn pack
| Tier | Pets | Food |
| ---- | ---- | ---- |
| 1 | Alchemedes, Cuddle Toad, Pengobble, Barghest, Basilisk, Baku | — |
| 2 | Thunderbird, Gargoyle, Frost Wolf, Mothman, Nightcrawler, Bigfoot | Fairy Dust |
| 3 | Fur-Bearing Trout, Calygreyhound, Tatzelwurm, Skeleton Dog, Mandrake, Lucky Cat, Slime | Water of Youth |
| 4 | Roc, Chimera, Kraken, Unicorn, Abomination, Rootlin, Fairy | Health Potion |
| 5 | Kitsune, Pixiu, Red Dragon, Amalgamation, Vampire Bat, Werewolf, Loveland Frogman | Big Mana Potion |
| 6 | Sleipnir, Sea Serpent, Bakunawa, Manticore, Team Spirit, Behemoth, Quetzalcoatl | Cornucopia |
The Unicorn pack adds two mechanics the others don't have:
- **Mana** — a **persistent** resource (unlike ephemeral Trumpets): pets earn
it in the shop (Cuddle Toad, Thunderbird, Roc) and in battle (Alchemedes,
Fur-Bearing Trout, Kitsune, Tatzelwurm per rock blank), and spend it to power
abilities (Pengobble, Gargoyle, Sea Serpent, Bakunawa; Sleipnir's very Power
equals your Mana). It carries over between rounds until spent, so you can bank
it up.
- **Ailments** — temporary debuff cards attached to a pet during battle,
removed at round end like apples. **Spooked** lowers the pet's clash attack
(1 each, min 0); **Exposed** raises the damage it takes per hit (+1 each).
They stack, can be added to the enemy pet or dropped on top of the enemy deck
(Frost Wolf, Slime, Kraken, Red Dragon). **Baku** shrugs off the first one it
takes, **Unicorn** turns the next friendly one into apples, and **Manticore**
makes enemy ailments count for more.
- Plus a **game-wide discard pile** the higher tiers reach into (Chimera,
Abomination), pet-recycling (Fairy, Loveland Frogman's bounce), and shop
tricks — Bigfoot's deck peek, Water of Youth's upgrade, Quetzalcoatl's reveal.
## Layout ## Layout
``` ```
+31 -2
View File
@@ -35,12 +35,12 @@ import (
// Action is one move the bot wants to make, mirroring the client protocol. // Action is one move the bot wants to make, mirroring the client protocol.
type Action struct { type Action struct {
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | sacrificeChoose
Row int // buy / buyAvocado Row int // buy / buyAvocado
Cards []string // sell / trade Cards []string // sell / trade
Pick int // tradeChoose Pick int // tradeChoose
Order []string // arrange Order []string // arrange
CardID string // revealChoose (Cockatoo): the pet to reveal CardID string // revealChoose (Cockatoo) / sacrificeChoose (Water of Youth): the pet
} }
// Bot is a computer player at a fixed difficulty level. // Bot is a computer player at a fixed difficulty level.
@@ -62,6 +62,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
me := &v.Players[v.YouSeat] me := &v.Players[v.YouSeat]
switch v.Phase { switch v.Phase {
case game.PhaseShop: case game.PhaseShop:
if v.PendingSacrifice != nil {
if v.PendingSacrifice.PlayerID == me.ID {
return b.decideSacrifice(v)
}
return nil
}
if v.PendingReveal != nil { if v.PendingReveal != nil {
if v.PendingReveal.PlayerID == me.ID { if v.PendingReveal.PlayerID == me.ID {
return b.decideReveal(v) return b.decideReveal(v)
@@ -107,6 +113,26 @@ func (b *Bot) decideReveal(v *game.View) *Action {
return &Action{Type: "revealChoose", CardID: best} return &Action{Type: "revealChoose", CardID: best}
} }
// decideSacrifice resolves Water of Youth (Unicorn pack): give up the
// lowest-value eligible pet to upgrade into a next-tier card.
func (b *Bot) decideSacrifice(v *game.View) *Action {
me := &v.Players[v.YouSeat]
worst, worstVal := "", math.Inf(1)
for _, id := range v.PendingSacrifice.Options {
for _, c := range me.Deck {
if c.ID == id {
if val := keepValue(c); val < worstVal {
worst, worstVal = id, val
}
}
}
}
if worst == "" && len(v.PendingSacrifice.Options) > 0 {
worst = v.PendingSacrifice.Options[0]
}
return &Action{Type: "sacrificeChoose", CardID: worst}
}
// Pending reports whether the seat owes the game an action right now — the // Pending reports whether the seat owes the game an action right now — the
// server uses it to decide when to schedule a bot move. // server uses it to decide when to schedule a bot move.
func Pending(v *game.View) bool { func Pending(v *game.View) bool {
@@ -116,6 +142,9 @@ func Pending(v *game.View) bool {
me := &v.Players[v.YouSeat] me := &v.Players[v.YouSeat]
switch v.Phase { switch v.Phase {
case game.PhaseShop: case game.PhaseShop:
if v.PendingSacrifice != nil {
return v.PendingSacrifice.PlayerID == me.ID
}
if v.PendingReveal != nil { if v.PendingReveal != nil {
return v.PendingReveal.PlayerID == me.ID return v.PendingReveal.PlayerID == me.ID
} }
+16
View File
@@ -102,6 +102,8 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
return g.TradeChoose(playerID, a.Pick) return g.TradeChoose(playerID, a.Pick)
case "revealChoose": case "revealChoose":
return g.RevealChoose(playerID, a.CardID) return g.RevealChoose(playerID, a.CardID)
case "sacrificeChoose":
return g.SacrificeChoose(playerID, a.CardID)
case "pass": case "pass":
return g.Pass(playerID) return g.Pass(playerID)
case "arrange": case "arrange":
@@ -139,6 +141,20 @@ func TestBotsFinishGoldenGame(t *testing.T) {
} }
} }
// TestBotsFinishUnicornGame plays complete games on the Unicorn pack (tiers
// 1-3 printed; 4-6 still in progress, so late shops are barren but battles
// still resolve). It exercises the Mana and Ailment mechanics via
// SimulateBattle rollouts plus the new shop effects (Water of Youth's sacrifice
// choice, Bigfoot), and fails on any illegal or missing bot action.
func TestBotsFinishUnicornGame(t *testing.T) {
for range 5 {
g := playBotGamePack(t, "unicorn", 1, 0.6)
if g.Round != game.MaxRounds {
t.Errorf("unicorn game ended on round %d, want %d", g.Round, game.MaxRounds)
}
}
}
// TestObserveTracksOpponentDeck checks the memory's opponent model against // TestObserveTracksOpponentDeck checks the memory's opponent model against
// the opponent's real deck after known public actions. The model may only // the opponent's real deck after known public actions. The model may only
// contain information a human spectator would have. // contain information a human spectator would have.
+47
View File
@@ -52,6 +52,20 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
for range best { for range best {
deck = append(deck, cx.simApple()) deck = append(deck, cx.simApple())
} }
case game.ActionRevealTierForApples:
// Quetzalcoatl: a fixed apple reward if a low-tier pet can be revealed.
eligible := false
for _, d := range deck {
if d.IsPet() && d.ID != c.ID && (e.Cap <= 0 || d.Tier <= e.Cap) {
eligible = true
break
}
}
if eligible {
for range max(e.Count, 1) {
deck = append(deck, cx.simApple())
}
}
case game.ActionDoubleApples: case game.ActionDoubleApples:
apples := 0 apples := 0
for _, dc := range deck { for _, dc := range deck {
@@ -87,6 +101,39 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 { if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
deck = slices.Delete(deck, i, i+1) deck = slices.Delete(deck, i, i+1)
} }
case game.ActionUpgradeNextTier:
// Unicorn pack (Water of Youth): discard this food and the lowest-value
// pet, then gain a sampled top card of the next tier for free.
nextTier := cx.v.Round + 1
if nextTier > game.MaxRounds {
continue
}
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
deck = slices.Delete(deck, i, i+1)
}
worst, worstVal := -1, 0.0
for i, d := range deck {
if !d.IsPet() {
continue
}
if v := keepValue(d); worst < 0 || v < worstVal {
worst, worstVal = i, v
}
}
if worst < 0 {
continue // no pet to sacrifice; the food is wasted
}
deck = slices.Delete(deck, worst, worst+1)
pool := cx.unseenPool(nextTier)
if len(pool) == 0 {
pool = game.TierContentsForPack(cx.v.Pack, nextTier)
}
if len(pool) > 0 {
rc := pool[rand.IntN(len(pool))]
rc.ID = cx.nextSimID()
deck = append(deck, rc)
deck = cx.applyTemplateShopEffects(deck, rc, game.TriggerBuy)
}
} }
} }
return deck return deck
+483 -20
View File
@@ -20,6 +20,12 @@ type BattleUnit struct {
// hitPrevent are this pet's own one-shot partial damage preventions // hitPrevent are this pet's own one-shot partial damage preventions
// (Potato perk: two charges of 2). Consumed before any side-level charge. // (Potato perk: two charges of 2). Consumed before any side-level charge.
hitPrevent []int 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 // 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) Power() int { return u.Card.Power + u.Bonus }
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } 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 // activePerk returns the perk this unit benefits from: only the
// last-applied perk counts when several are attached. // last-applied perk counts when several are attached.
func (u *BattleUnit) activePerk() *Card { func (u *BattleUnit) activePerk() *Card {
@@ -124,6 +149,11 @@ type BattleEvent struct {
// Turkey, Mammoth). // Turkey, Mammoth).
// "release": a set-aside Card left Seat's zone, its effect consumed // "release": a set-aside Card left Seat's zone, its effect consumed
// (matched by Card.ID). // (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"` Type string `json:"type"`
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first // Seat/Target must NOT be omitempty: 0 is a valid seat (the first
// player) and dropping it makes the client read sides[undefined]. // 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 // decisive the result was — the margin the AI uses to prefer a lineup that
// fights harder, even in a battle it can't win. // fights harder, even in a battle it can't win.
Survivors []int `json:"survivors,omitempty"` 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. // 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 feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
petsPlayed int // pets fielded so far (Komodo's "first pet") petsPlayed int // pets fielded so far (Komodo's "first pet")
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits 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. // 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 { for _, p := range g.Players {
p.PendingApplesInPlay = 0 p.PendingApplesInPlay = 0
p.PendingTrumpets = 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 { for _, p := range g.Players {
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} 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 sides[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck) res.StackSizes[p.Seat] = len(p.Deck)
res.Lineups[p.Seat] = append([]Card(nil), 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, emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(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 // gainMana adds Mana to a side's persistent pool and narrates it.
// spend. Returns false (without paying) when the side can't afford it, so gainMana := func(seat, n int, cause string) {
// the caller skips the effect. A zero-cost effect always "pays". if n <= 0 {
spend := func(seat int, e Effect, cause string) bool { return
if e.CostTrumpet <= 0 {
return true
} }
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 return false
} }
sides[seat].trumpets -= e.CostTrumpet if e.CostTrumpet > 0 {
s.trumpets -= e.CostTrumpet
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -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))}) 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 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 gates battle-time effects on their conditions.
allowed := func(e Effect, u *BattleUnit) bool { allowed := func(e Effect, u *BattleUnit) bool {
@@ -444,10 +586,22 @@ func (g *Game) runBattle() *BattleResult {
return u != nil && u.activePerk() != nil return u != nil && u.activePerk() != nil
case ConditionTripled: case ConditionTripled:
return false // shop-time condition; never true in battle return false // shop-time condition; never true in battle
case ConditionEvenRound:
return g.Round%2 == 0
} }
return true 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 // hitUnit applies one attack against a seat's pet: shields block the
// whole hit, Garlic shaves per-attack damage. Returns the damage dealt and, // whole hit, Garlic shaves per-attack damage. Returns the damage dealt and,
// when a shield absorbed the hit, a shieldBlock describing it (nil // 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 { if u == nil || amount <= 0 {
return 0, nil, nil 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 { if sides[seat].shields > 0 {
sides[seat].shields-- sides[seat].shields--
var card *Card var card *Card
@@ -539,6 +696,10 @@ func (g *Game) runBattle() *BattleResult {
if isBee(u.Card) { if isBee(u.Card) {
s.beesFainted++ 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 // Track distinct suits among friendly fainted pets (Honduran White
// Bat). Bees and the Golden Retriever have no suit. // Bat). Bees and the Golden Retriever have no suit.
if !isBee(u.Card) && u.Card.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, emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s is set aside.", c.Name)}) 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) cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
for _, e := range u.effects() { for _, e := range u.effects() {
if e.Trigger != TriggerFaint || !allowed(e, u) { if e.Trigger != TriggerFaint || !allowed(e, u) {
@@ -643,7 +823,78 @@ func (g *Game) runBattle() *BattleResult {
case ActionPetAura: case ActionPetAura:
s.petBonus += e.count() s.petBonus += e.count()
setAside() 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). // Enemy Faints triggers on surviving pets elsewhere (Hippo).
for other, os := range sides { for other, os := range sides {
@@ -699,20 +950,32 @@ func (g *Game) runBattle() *BattleResult {
for range e.count() { for range e.count() {
u.Shields = append(u.Shields, shieldCharge{}) 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 // 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) { afterAttack := func(seat int, u *BattleUnit) {
if !u.Alive() || u.afterAttackUsed { if !u.Alive() {
return return
} }
for _, e := range u.effects() { for _, e := range u.effects() {
if e.Trigger != TriggerAfterAttack || !allowed(e, u) { if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
continue 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) { if !spend(seat, e, u.Card.Name) {
continue continue
} }
@@ -725,24 +988,30 @@ func (g *Game) runBattle() *BattleResult {
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)}) Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
} }
if e.Once {
u.afterAttackUsed = true u.afterAttackUsed = true
} }
} }
}
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are // throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
// not "attacks with" the pet, so no knockout applies. Reports a kill. // not "attacks with" the pet, so no knockout applies. Reports a kill and how
throwRocks := func(from, target, dice int, source *Card) (killed bool) { // 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 tu := sides[target].unit
if tu == nil || dice <= 0 { if tu == nil || dice <= 0 {
// No target, or a Per-scaled volley that came out to zero (Royal // No target, or a Per-scaled volley that came out to zero (Royal
// Flycatcher / Grizzly with no fainted pets): nothing to animate. // Flycatcher / Grizzly with no fainted pets): nothing to animate.
return false return false, 0, 0
} }
roll := 0 roll := 0
faces := make([]int, dice) faces := make([]int, dice)
for i := range dice { for i := range dice {
faces[i] = g.rollRockDie() faces[i] = g.rollRockDie()
roll += faces[i] roll += faces[i]
if faces[i] == 0 {
blanks++
}
} }
dealt, block, prev := hitUnit(target, roll) dealt, block, prev := hitUnit(target, roll)
died := !tu.Alive() died := !tu.Alive()
@@ -790,12 +1059,12 @@ func (g *Game) runBattle() *BattleResult {
} }
faint(target, tu) faint(target, tu)
sides[target].unit = nil sides[target].unit = nil
return true return true, blanks, dealt
} }
if dealt > 0 { if dealt > 0 {
hurt(target, tu) hurt(target, tu)
} }
return false return false, blanks, dealt
} }
// nextTarget finds the seat whose pet a standard enemy-directed play // 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) s.pending = append(s.pending, c)
continue 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 := &BattleUnit{Card: c, Foods: s.pending}
u.Bonus += appleCount(s.pending) u.Bonus += appleCount(s.pending)
u.Bonus += s.petBonus u.Bonus += s.petBonus
if isBee(c) { if isBee(c) {
u.Bonus += s.beeBonus 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 // Pets carry their starting bonus (foods + auras) in the
// reveal event so clients can display it directly. // reveal event so clients can display it directly.
revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name) 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, 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)}) 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 // 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 // play-time shield knows its source (an innate pet block vs a
// Melon perk that should be shown and later dropped). // Melon perk that should be shown and later dropped).
@@ -1020,18 +1346,18 @@ func (g *Game) runBattle() *BattleResult {
switch { switch {
case q.everyone: case q.everyone:
for seat := range sides { for seat := range sides {
if throwRocks(q.seat, seat, dice, q.source) { if killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed {
anyDeath = true anyDeath = true
} }
} }
case q.effect.Target == "self": case q.effect.Target == "self":
// Manatee pelts its own pet. // 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 anyDeath = true
} }
default: default:
if t := nextTarget(q.seat); t >= 0 { 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 anyDeath = true
} }
} }
@@ -1039,6 +1365,29 @@ func (g *Game) runBattle() *BattleResult {
if q.release != nil { if q.release != nil {
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release}) 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: case ActionGainTrumpet:
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName) gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
case ActionDoubleTrumpets: 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)) summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
} }
case ActionEatApple: 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)) count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
if count > 0 { if count > 0 {
for range count { for range count {
@@ -1193,11 +1549,111 @@ func (g *Game) runBattle() *BattleResult {
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice, emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))}) Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
if t := nextTarget(q.seat); t >= 0 { 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 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 // 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 // Clash. Two-player for now; >2-player battle pairings come later
// (the surrounding state is already per-seat). // (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit 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) dealtA, blockA, prevA := hitUnit(0, powB)
dealtB, blockB, prevB := hitUnit(1, powA) dealtB, blockB, prevB := hitUnit(1, powA)
// Scorpion: a clash attack that hurts, KOs. // 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 // Record each side's leftover force: a live pet in play plus any pets never
// reached in the stack. The loser lands on 0. // reached in the stack. The loser lands on 0.
res.Survivors = make([]int, n) res.Survivors = make([]int, n)
res.ManaAfter = make([]int, n)
res.NextRoundApples = make([]int, n)
for seat, s := range sides { for seat, s := range sides {
cnt := 0 cnt := 0
if s.unit != nil && s.unit.Alive() { if s.unit != nil && s.unit.Alive() {
@@ -1332,6 +1793,8 @@ func (g *Game) runBattle() *BattleResult {
} }
} }
res.Survivors[seat] = cnt res.Survivors[seat] = cnt
res.ManaAfter[seat] = s.mana
res.NextRoundApples[seat] = s.nextRoundApples
} }
return res return res
} }
+438 -1
View File
@@ -19,6 +19,9 @@ type CardKind string
const ( const (
KindPet CardKind = "pet" KindPet CardKind = "pet"
KindFood CardKind = "food" 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. // Food identifiers.
@@ -37,6 +40,15 @@ const (
FoodPotato = "potato" FoodPotato = "potato"
FoodDurian = "durian" FoodDurian = "durian"
FoodTomato = "tomato" 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. // EffectTrigger is when an effect fires.
@@ -209,6 +221,91 @@ const (
// Retriever, when summoned, prevents Count damage on its first hit (German // Retriever, when summoned, prevents Count damage on its first hit (German
// Shepherd). // Shepherd).
ActionGuardRetriever EffectAction = "guardRetriever" 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. // Per multipliers for dynamic effect counts.
@@ -236,6 +333,19 @@ const (
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
ConditionHasPerk = "hasPerk" // this pet has a perk attached ConditionHasPerk = "hasPerk" // this pet has a perk attached
ConditionFirstPet = "firstPet" // this is the first pet the side has played (Komodo) 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. // 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 to fire (Golden pack). It's auto-paid when affordable and the
// effect is skipped otherwise — battles take no player input. // effect is skipped otherwise — battles take no player input.
CostTrumpet int `json:"costTrumpet,omitempty"` 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 // 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 // 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 // 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 // Temporary cards (apples, bees) are removed from the deck after the
// next battle. // next battle.
Temporary bool `json:"temporary,omitempty"` 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) IsPet() bool { return c.Kind == KindPet }
func (c Card) IsFood() bool { return c.Kind == KindFood } 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 // petTemplate is the printed definition of a pet. Suits lists the suit of
// each physical copy in the tier deck (one card per entry). // 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}, 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)", 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. // newCardID mints a unique card ID within the game.
func (g *Game) newCardID() string { func (g *Game) newCardID() string {
g.NextCardID++ g.NextCardID++
@@ -866,6 +1284,8 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla
switch pack { switch pack {
case "golden": case "golden":
return &goldenPetTiers, &goldenFoodTiers return &goldenPetTiers, &goldenFoodTiers
case "unicorn":
return &unicornPetTiers, &unicornFoodTiers
default: // turtle (and the placeholder packs, until they ship) default: // turtle (and the placeholder packs, until they ship)
return &petTiers, &foodTiers 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 // 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 // other pets' effects. It counts as a pet while it exists (foods can attach
// to it in battle). // to it in battle).
+186 -1
View File
@@ -66,6 +66,19 @@ type Player struct {
// PendingTrumpets (Golden pack: Bird of Paradise) is Trumpets the next // PendingTrumpets (Golden pack: Bird of Paradise) is Trumpets the next
// battle starts with in the pool; reset after that battle. // battle starts with in the pool; reset after that battle.
PendingTrumpets int `json:"pendingTrumpets,omitempty"` 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 // IsBot marks a computer-controlled seat. The engine treats bots exactly
// like humans; the server drives their actions. BotLevel is the bot's // 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 // 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"` PlayerID string `json:"playerId"`
Source string `json:"source"` // the Cockatoo's card id Source string `json:"source"` // the Cockatoo's card id
Options []string `json:"options"` // eligible pet card ids in the buyer's deck 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 // Game is the complete authoritative state. It is a pure state machine: no
@@ -120,6 +147,10 @@ type Game struct {
Players []*Player `json:"players"` Players []*Player `json:"players"`
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
ShopRow []Card `json:"shopRow"` // empty ID = empty slot 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 Turn int `json:"turn"` // seat with the current shop turn
// PrioritySeat holds the priority token: that seat shops first each round // PrioritySeat holds the priority token: that seat shops first each round
// and wins simultaneity races in battle. Assigned randomly at game start; // and wins simultaneity races in battle. Assigned randomly at game start;
@@ -130,6 +161,9 @@ type Game struct {
// PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks // PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks
// other shop actions on that seat until resolved, like Pending. // other shop actions on that seat until resolved, like Pending.
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"` PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
// 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 Battle *BattleResult `json:"battle,omitempty"` // most recent battle
NextCardID int `json:"nextCardId"` NextCardID int `json:"nextCardId"`
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
@@ -350,12 +384,22 @@ func (g *Game) startShopRound() {
g.Phase = PhaseShop g.Phase = PhaseShop
g.Pending = nil g.Pending = nil
g.PendingReveal = nil g.PendingReveal = nil
g.PendingSacrifice = nil
for _, p := range g.Players { for _, p := range g.Players {
p.Coins = CoinsPerRound p.Coins = CoinsPerRound
p.Ready = false p.Ready = false
p.TripledThisRound = false p.TripledThisRound = false
p.FirstBuyFree = false p.FirstBuyFree = false
p.BuysThisRound = 0 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) g.ShopRow = make([]Card, ShopRowSize)
for i := range g.ShopRow { for i := range g.ShopRow {
@@ -402,6 +446,9 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) {
if g.PendingReveal != nil { if g.PendingReveal != nil {
return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction) 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 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 // advanceAfterBuy hands off the turn after a buy, unless the buy opened a
// Cockatoo reveal that the same player must resolve first. // Cockatoo reveal that the same player must resolve first.
func (g *Game) advanceAfterBuy() { func (g *Game) advanceAfterBuy() {
if g.PendingReveal != nil { if g.PendingReveal != nil || g.PendingSacrifice != nil {
return return
} }
g.advanceShopTurn() g.advanceShopTurn()
@@ -552,6 +599,22 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
if len(opts) > 0 { if len(opts) > 0 {
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts} 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: case ActionApplesInPlay:
// Golden pack: apples-in-play banked when sold (Hercules Beetle) or // Golden pack: apples-in-play banked when sold (Hercules Beetle) or
// bought (Bird of Paradise). Monkey's battle-prep version is resolved // 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, 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)}) 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 { for _, c := range sold {
p.Deck = append(p.Deck, g.newApple()) 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", 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)}) Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)})
g.applyShopTrigger(p, c, TriggerSell) g.applyShopTrigger(p, c, TriggerSell)
@@ -711,6 +802,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
for _, id := range cardIDs { for _, id := range cardIDs {
idx := p.cardIndex(id) idx := p.cardIndex(id)
traded = append(traded, p.Deck[idx]) traded = append(traded, p.Deck[idx])
g.discardCard(p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1) p.Deck = slices.Delete(p.Deck, idx, idx+1)
} }
p.TripledThisRound = true p.TripledThisRound = true
@@ -785,6 +877,9 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
} }
revealed := p.Deck[idx] revealed := p.Deck[idx]
n := revealed.Power n := revealed.Power
if g.PendingReveal.Apples > 0 {
n = g.PendingReveal.Apples // Quetzalcoatl: fixed reward
}
for range n { for range n {
p.Deck = append(p.Deck, g.newApple()) p.Deck = append(p.Deck, g.newApple())
} }
@@ -797,6 +892,83 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
return nil 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, // 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 // free and off-turn — a testing aid gated behind the server's DEBUG flag, not
// a normal action. No buy effects fire. // 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 { func hasDuplicates(ids []string) bool {
seen := make(map[string]struct{}, len(ids)) seen := make(map[string]struct{}, len(ids))
for _, id := range ids { for _, id := range ids {
+3
View File
@@ -34,6 +34,9 @@ const (
LogSell = "sell" // Seat sold Source/CardName (it became an apple) LogSell = "sell" // Seat sold Source/CardName (it became an apple)
LogTrade = "trade" // Seat traded in Cards for a next-tier pick LogTrade = "trade" // Seat traded in Cards for a next-tier pick
LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed 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 // addLog appends an entry, stamping it with the next sequence number and the
+2 -4
View File
@@ -1,9 +1,7 @@
package game package game
// Card packs are the selectable sets of pets and food a game is played with. // 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 // Turtle, Golden, and Unicorn all ship with full six-tier card data.
// infrastructure (shown but not yet playable) so the lobby, views, and
// deck-building all have a single source of truth to grow into.
// PackInfo describes one selectable pack. Playable gates whether a lobby may // PackInfo describes one selectable pack. Playable gates whether a lobby may
// choose it and start a game with it. // choose it and start a game with it.
@@ -21,7 +19,7 @@ const DefaultPack = "turtle"
var Packs = []PackInfo{ var Packs = []PackInfo{
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true}, {ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
{ID: "golden", Name: "Golden 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. // 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)
}
}
+18
View File
@@ -23,6 +23,12 @@ type PlayerView struct {
// Trumpets is the count banked for the next battle (Bird of Paradise). // Trumpets is the count banked for the next battle (Bird of Paradise).
// Self-only — shown under the player's own deck. // 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 Deck []Card `json:"deck,omitempty"` // self only
} }
@@ -56,6 +62,9 @@ type View struct {
// see a reveal is in progress; the eligible options are only sent to the // see a reveal is in progress; the eligible options are only sent to the
// buyer (they name the buyer's own hidden pets). // buyer (they name the buyer's own hidden pets).
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"` PendingReveal *PendingReveal `json:"pendingReveal,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"` Battle *BattleResult `json:"battle,omitempty"`
WinnerSeat int `json:"winnerSeat"` WinnerSeat int `json:"winnerSeat"`
// Log is the shared, public event log shown across every phase. // Log is the shared, public event log shown across every phase.
@@ -102,6 +111,7 @@ func (g *Game) ViewFor(playerID string) View {
DeckSize: len(p.Deck), DeckSize: len(p.Deck),
PetCount: p.PetCount(), PetCount: p.PetCount(),
Avocados: p.Avocados, Avocados: p.Avocados,
Mana: p.Mana,
} }
if p.ID == playerID { if p.ID == playerID {
v.YouSeat = p.Seat v.YouSeat = p.Seat
@@ -109,6 +119,7 @@ func (g *Game) ViewFor(playerID string) View {
pv.FirstBuyFree = p.FirstBuyFree pv.FirstBuyFree = p.FirstBuyFree
pv.BuysThisRound = p.BuysThisRound pv.BuysThisRound = p.BuysThisRound
pv.Trumpets = p.PendingTrumpets pv.Trumpets = p.PendingTrumpets
pv.ShopPeek = p.ShopPeek
} }
v.Players = append(v.Players, pv) v.Players = append(v.Players, pv)
} }
@@ -126,6 +137,13 @@ func (g *Game) ViewFor(playerID string) View {
} }
v.PendingReveal = &reveal 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 results (lineups, events) are public once resolved. Keep the
// battle around during the following shop phase too, so late joiners / // battle around during the following shop phase too, so late joiners /
// reconnects can still see the last result. // reconnects can still see the last result.
+5
View File
@@ -161,6 +161,8 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
return g.TradeChoose(playerID, a.Pick) return g.TradeChoose(playerID, a.Pick)
case "revealChoose": case "revealChoose":
return g.RevealChoose(playerID, a.CardID) return g.RevealChoose(playerID, a.CardID)
case "sacrificeChoose":
return g.SacrificeChoose(playerID, a.CardID)
case "pass": case "pass":
return g.Pass(playerID) return g.Pass(playerID)
case "arrange": case "arrange":
@@ -182,6 +184,9 @@ func botFallback(g *game.Game, playerID string) error {
} }
switch g.Phase { switch g.Phase {
case game.PhaseShop: case game.PhaseShop:
if g.PendingSacrifice != nil && g.PendingSacrifice.PlayerID == playerID {
return g.SacrificeChoose(playerID, g.PendingSacrifice.Options[0])
}
if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID { if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID {
return g.RevealChoose(playerID, g.PendingReveal.Options[0]) return g.RevealChoose(playerID, g.PendingReveal.Options[0])
} }
+4
View File
@@ -151,6 +151,10 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
err = g.TradeChoose(c.playerID, msg.Pick) err = g.TradeChoose(c.playerID, msg.Pick)
case "revealChoose": case "revealChoose":
err = g.RevealChoose(c.playerID, msg.Card) err = g.RevealChoose(c.playerID, msg.Card)
case "sacrificeChoose":
err = g.SacrificeChoose(c.playerID, msg.Card)
case "peek":
err = g.PeekShopDeck(c.playerID)
case "pass": case "pass":
err = g.Pass(c.playerID) err = g.Pass(c.playerID)
case "arrange": case "arrange":
+60 -2
View File
@@ -23,6 +23,8 @@ interface UnitVis {
bonus: number bonus: number
damage: number damage: number
dying: boolean dying: boolean
spooked: number // Unicorn pack ailment: lowers the pet's clash attack
exposed: number // Unicorn pack ailment: raises damage it takes per hit
} }
interface SideVis { interface SideVis {
@@ -54,6 +56,9 @@ const EVENT_MS: Record<BattleEvent['type'], number> = {
release: 500, release: 500,
trumpet: 800, trumpet: 800,
prevent: 900, prevent: 900,
mana: 800,
ailment: 900,
bounce: 1000,
} }
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
@@ -104,12 +109,16 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
bonus: 0, bonus: 0,
damage: 0, damage: 0,
dying: false, dying: false,
spooked: 0,
exposed: 0,
} }
break break
} }
s.stack-- s.stack--
const card = ev.card! const card = ev.card!
if (card.kind === 'food') { if (card.kind === 'food' || card.kind === 'ailment') {
// Ailments waiting on top of the deck sit in the pending fan until a
// pet arrives; the backend then emits an 'ailment' event to attach it.
s.pending.push(card) s.pending.push(card)
} else { } else {
s.unit = { s.unit = {
@@ -118,6 +127,8 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
bonus: ev.bonus ?? appleCount(s.pending), bonus: ev.bonus ?? appleCount(s.pending),
damage: 0, damage: 0,
dying: false, dying: false,
spooked: 0,
exposed: 0,
} }
s.pending = [] s.pending = []
} }
@@ -187,6 +198,24 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
break // pure animation; no state change break // pure animation; no state change
case 'trumpet': case 'trumpet':
break // pure animation; the pool isn't drawn on the board break // pure animation; the pool isn't drawn on the board
case 'mana':
break // pure animation; the Mana pool isn't drawn on the board
case 'ailment': {
// A pet gained (count > 0) an ailment; count 0 means Baku shrugged it.
const u = sides[ev.seat!].unit
const kind = ev.card?.ailment
if (u && ev.count && kind === 'spooked') u.spooked += ev.count
else if (u && ev.count && kind === 'exposed') u.exposed += ev.count
break
}
case 'bounce': {
// The target pet is sent to the bottom of its deck: it leaves play and
// the deck grows by one.
const t = sides[ev.target!]
t.unit = null
t.stack++
break
}
case 'prevent': { case 'prevent': {
// Cone Snail shaved damage off the hit; the reduced total rides along. // Cone Snail shaved damage off the hit; the reduced total rides along.
const u = sides[ev.seat!].unit const u = sides[ev.seat!].unit
@@ -228,6 +257,14 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st
case 'trumpet': case 'trumpet':
if (ev.seat !== seat) return null if (ev.seat !== seat) return null
return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}` return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}`
case 'mana':
if (ev.seat !== seat) return null
return (ev.count ?? 0) >= 0 ? `🔮 +${ev.count}` : `🔮 ${ev.count}`
case 'ailment':
if (ev.seat !== seat) return null
return ev.card?.ailment === 'spooked' ? '👻' : '🎯'
case 'bounce':
return ev.target === seat ? '🌀' : null
case 'eat': case 'eat':
return ev.seat === seat ? '🍎' : null return ev.seat === seat ? '🍎' : null
case 'heal': case 'heal':
@@ -337,7 +374,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
!done && !done &&
lastEvent?.seat === seat && lastEvent?.seat === seat &&
(lastEvent.type === 'prep' || (lastEvent.type === 'prep' ||
(lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food')) (lastEvent.type === 'reveal' &&
(lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment')))
? lastEvent.card?.id ? lastEvent.card?.id
: null : null
// A pet just set aside slides into the set-aside row beside the arena. // A pet just set aside slides into the set-aside row beside the arena.
@@ -450,6 +488,26 @@ export function BattlePhase({ view, send, step, setStep }: Props) {
damage={s.unit.damage} damage={s.unit.damage}
dead={s.unit.dying} dead={s.unit.dying}
/> />
{(s.unit.spooked > 0 || s.unit.exposed > 0) && (
<div className="ailment-badges">
{s.unit.spooked > 0 && (
<span
className="ailment-badge ailment-spooked"
title={`Spooked ×${s.unit.spooked}: deals ${s.unit.spooked} less damage`}
>
👻{s.unit.spooked > 1 ? s.unit.spooked : ''}
</span>
)}
{s.unit.exposed > 0 && (
<span
className="ailment-badge ailment-exposed"
title={`Exposed ×${s.unit.exposed}: takes ${s.unit.exposed} extra damage per hit`}
>
🎯{s.unit.exposed > 1 ? s.unit.exposed : ''}
</span>
)}
</div>
)}
{pop && ( {pop && (
<div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}> <div key={`pop-${step}`} className={pop.startsWith('') ? 'damage-pop' : 'fx-pop'}>
{pop} {pop}
+5 -1
View File
@@ -180,7 +180,7 @@ export function CardView({
const classes = [ const classes = [
'card', 'card',
`card-${size}`, `card-${size}`,
card.kind === 'food' ? 'card-food' : 'card-pet', card.kind === 'ailment' ? 'card-ailment' : card.kind === 'food' ? 'card-food' : 'card-pet',
selected ? 'is-selected' : '', selected ? 'is-selected' : '',
disabled ? 'is-disabled' : '', disabled ? 'is-disabled' : '',
dead ? 'is-dead' : '', dead ? 'is-dead' : '',
@@ -228,6 +228,10 @@ export function CardView({
? renderEffect(card.effectText) ? renderEffect(card.effectText)
: card.kind === 'food' && card.food === 'apple' : card.kind === 'food' && card.food === 'apple'
? '+1 power (this battle)' ? '+1 power (this battle)'
: card.ailment === 'spooked'
? 'Deals 1 less damage'
: card.ailment === 'exposed'
? 'Takes 1 extra damage'
: ''} : ''}
</div> </div>
{selected && <div className="card-check"></div>} {selected && <div className="card-check"></div>}
+3
View File
@@ -28,6 +28,9 @@ const BATTLE_ICONS: Record<BattleEvent['type'], string> = {
release: '↩️', release: '↩️',
trumpet: '🎺', trumpet: '🎺',
prevent: '🛡️', prevent: '🛡️',
mana: '🔮',
ailment: '👻',
bounce: '🌀',
} }
// battleLogLines turns the battle events revealed up to `step` into readable // battleLogLines turns the battle events revealed up to `step` into readable
+49
View File
@@ -74,6 +74,11 @@ export function ShopPhase({ view, you, send }: Props) {
const myTurn = view.turn === view.youSeat && !you.ready const myTurn = view.turn === view.youSeat && !you.ready
const avocados = you.avocados ?? 0 const avocados = you.avocados ?? 0
const trumpets = you.trumpets ?? 0 const trumpets = you.trumpets ?? 0
const mana = you.mana ?? 0
const shopPeek = you.shopPeek
const hasBigfoot = (you.deck ?? []).some((c) =>
(c.effects as { action?: string }[] | undefined)?.some((e) => e.action === 'peekShop'),
)
const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold
const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy) const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy)
// Nudge the player to pass once there's nothing left to buy. // Nudge the player to pass once there's nothing left to buy.
@@ -382,6 +387,28 @@ export function ShopPhase({ view, you, send }: Props) {
🎺 ×{trumpets} 🎺 ×{trumpets}
</div> </div>
)} )}
{mana > 0 && (
<div className="trumpet-indicator" title="Mana — a persistent resource that powers Unicorn abilities">
🔮 ×{mana}
</div>
)}
{hasBigfoot && (
<div className="peek-indicator">
<button
className="btn btn-ghost btn-sm"
disabled={!myTurn || !!shopPeek}
onClick={() => send({ type: 'peek' })}
title="Bigfoot: look at the top of the shop deck (once per round)"
>
👁 Peek shop deck
</button>
{shopPeek && (
<div className="peek-card" title="Next card off the shop deck">
<CardView card={shopPeek} size="sm" />
</div>
)}
</div>
)}
</section> </section>
{/* Actions */} {/* Actions */}
@@ -496,6 +523,28 @@ export function ShopPhase({ view, you, send }: Props) {
</div> </div>
)} )}
{/* Water of Youth sacrifice picker (Unicorn pack) */}
{view.pendingSacrifice?.playerId === you.id && (
<div className="modal-backdrop">
<div className="modal">
<h3>Sacrifice a pet to draw a free tier {view.pendingSacrifice.tier} card</h3>
<div className="modal-cards">
{(view.pendingSacrifice.options ?? []).map((id) => {
const c = deck.find((d) => d.id === id)
return c ? (
<CardView
key={id}
card={c}
size="lg"
onClick={() => send({ type: 'sacrificeChoose', card: id })}
/>
) : null
})}
</div>
</div>
</div>
)}
{cardFlyer && {cardFlyer &&
createPortal( createPortal(
<div <div
+3
View File
@@ -173,6 +173,9 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{(p.avocados ?? 0) > 0 && ( {(p.avocados ?? 0) > 0 && (
<span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span> <span className="chip" title="Set-aside Avocados">🥑 {p.avocados}</span>
)} )}
{(p.mana ?? 0) > 0 && (
<span className="chip" title="Mana pool">🔮 {p.mana}</span>
)}
</div> </div>
))} ))}
</div> </div>
+21
View File
@@ -23,6 +23,27 @@ const PET_EMOJI: Record<string, string> = {
Nyala: '🦌', 'Nurse Shark': '🦈', 'Giant Isopod': '🦞', 'Blue-Ringed Octopus': '🐙', Raccoon: '🦝', 'Fire Ant': '🐜', Macaque: '🐒', Nyala: '🦌', 'Nurse Shark': '🦈', 'Giant Isopod': '🦞', 'Blue-Ringed Octopus': '🐙', Raccoon: '🦝', 'Fire Ant': '🐜', Macaque: '🐒',
// Golden pack — Tier 6 // Golden pack — Tier 6
'Highland Cow': '🐄', Wildebeest: '🐃', 'Grizzly Bear': '🐻', Catfish: '🐟', Komodo: '🦎', 'Bird of Paradise': '🦚', 'German Shepherd': '🐕‍🦺', 'Highland Cow': '🐄', Wildebeest: '🐃', 'Grizzly Bear': '🐻', Catfish: '🐟', Komodo: '🦎', 'Bird of Paradise': '🦚', 'German Shepherd': '🐕‍🦺',
// Unicorn pack — Tier 1
Alchemedes: '🧪', 'Cuddle Toad': '🐸', Pengobble: '🐧', Barghest: '🐺', Basilisk: '🐍', Baku: '🦤',
// Unicorn pack — Tier 2
Thunderbird: '🦅', Gargoyle: '🗿', 'Frost Wolf': '🐺', Mothman: '🦋', Nightcrawler: '🐛', Bigfoot: '🦍',
// Unicorn pack — Tier 3
'Fur-Bearing Trout': '🐟', Calygreyhound: '🐕', Tatzelwurm: '🐲', 'Skeleton Dog': '💀', Mandrake: '🌱', 'Lucky Cat': '🐱', Slime: '🟢',
// Unicorn pack — Tier 4
Roc: '🦅', Chimera: '🐐', Kraken: '🦑', Unicorn: '🦄', Abomination: '🧟', Rootlin: '🌿', Fairy: '🧚',
// Unicorn pack — Tier 5
Kitsune: '🦊', Pixiu: '🦁', 'Red Dragon': '🐉', Amalgamation: '🧬', 'Vampire Bat': '🧛', Werewolf: '🐺', 'Loveland Frogman': '🐸',
// Unicorn pack — Tier 6
Sleipnir: '🐎', 'Sea Serpent': '🦕', Bakunawa: '🐍', Manticore: '🦂', 'Team Spirit': '🎏', Behemoth: '🐘', Quetzalcoatl: '🪶',
// Unicorn pack tokens & foods
Mana: '🔮',
Spooked: '👻',
Exposed: '🎯',
'Fairy Dust': '✨',
'Water of Youth': '⏳',
'Health Potion': '❤️‍🩹',
'Big Mana Potion': '🍶',
Cornucopia: '🧺',
// Summons & foods // Summons & foods
Bee: '🐝', Bee: '🐝',
Apple: '🍎', Apple: '🍎',
+45
View File
@@ -1392,6 +1392,51 @@ h3 {
color: var(--gold); color: var(--gold);
} }
/* Bigfoot's shop-deck peek (Unicorn pack): a button and the revealed card. */
.peek-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 12px auto 0;
width: fit-content;
}
/* Unicorn ailment cards: a cold, spectral palette distinct from foods/pets. */
.card-ailment {
background: linear-gradient(168deg, #e9e6f7, #cfc7ec 55%, #b3a8dd);
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
}
/* Ailment badges pinned to a battle unit. */
.ailment-badges {
position: absolute;
top: -8px;
left: -8px;
display: flex;
gap: 3px;
z-index: 5;
}
.ailment-badge {
font-size: 0.8rem;
font-weight: 800;
line-height: 1;
padding: 3px 5px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.35);
background: rgba(20, 16, 40, 0.82);
color: #fff;
}
.ailment-spooked {
box-shadow: 0 0 6px rgba(150, 130, 255, 0.7);
}
.ailment-exposed {
box-shadow: 0 0 6px rgba(255, 120, 90, 0.7);
}
/* Coins shown as big golden discs above the buy row. */ /* Coins shown as big golden discs above the buy row. */
.shop-coins { .shop-coins {
display: flex; display: flex;
+20 -1
View File
@@ -1,7 +1,7 @@
// Mirrors of the Go view types (internal/game/view.go). // Mirrors of the Go view types (internal/game/view.go).
export type Suit = 'red' | 'blue' | 'yellow' export type Suit = 'red' | 'blue' | 'yellow'
export type CardKind = 'pet' | 'food' export type CardKind = 'pet' | 'food' | 'ailment'
export type Phase = 'lobby' | 'shop' | 'arrange' | 'battle' | 'gameover' export type Phase = 'lobby' | 'shop' | 'arrange' | 'battle' | 'gameover'
export interface Card { export interface Card {
@@ -16,6 +16,7 @@ export interface Card {
food?: string food?: string
perk?: boolean perk?: boolean
temporary?: boolean temporary?: boolean
ailment?: string // 'spooked' | 'exposed' (Unicorn pack; kind === 'ailment')
} }
export interface PlayerView { export interface PlayerView {
@@ -33,6 +34,8 @@ export interface PlayerView {
firstBuyFree?: boolean // Manta Ray: next buy is free (self only) firstBuyFree?: boolean // Manta Ray: next buy is free (self only)
buysThisRound?: number // Blue-Ringed Octopus counter (self only) buysThisRound?: number // Blue-Ringed Octopus counter (self only)
trumpets?: number // banked Trumpets for next battle (Bird of Paradise, self only) trumpets?: number // banked Trumpets for next battle (Bird of Paradise, self only)
mana?: number // persistent Mana pool (Unicorn pack; public)
shopPeek?: Card // Bigfoot's look at the top of the shop deck (self only)
deck?: Card[] deck?: Card[]
} }
@@ -47,6 +50,16 @@ export interface PendingReveal {
playerId: string playerId: string
source: string source: string
options?: string[] // eligible pet card ids (buyer only) options?: string[] // eligible pet card ids (buyer only)
apples?: number // fixed apple reward (Quetzalcoatl); 0 = revealed pet's power (Cockatoo)
}
// Water of Youth (Unicorn pack): the buyer must sacrifice one of their pets to
// upgrade into a next-tier card.
export interface PendingSacrifice {
playerId: string
source: string
tier: number
options?: string[] // eligible pet card ids to sacrifice (buyer only)
} }
export interface BattleEvent { export interface BattleEvent {
@@ -66,6 +79,9 @@ export interface BattleEvent {
| 'release' | 'release'
| 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack) | 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack)
| 'prevent' // a Cone Snail shaves damage off a hit (Golden pack) | 'prevent' // a Cone Snail shaves damage off a hit (Golden pack)
| 'mana' // a side gains (+) or spends (-) Mana (Unicorn pack)
| 'ailment' // a pet gains Ailment cards (Unicorn pack)
| 'bounce' // a pet is sent to the bottom of its deck (Unicorn: Loveland Frogman)
seat?: number seat?: number
target?: number target?: number
card?: Card card?: Card
@@ -131,6 +147,7 @@ export interface GameView {
players: PlayerView[] players: PlayerView[]
pending?: PendingTrade pending?: PendingTrade
pendingReveal?: PendingReveal pendingReveal?: PendingReveal
pendingSacrifice?: PendingSacrifice
battle?: BattleResult battle?: BattleResult
winnerSeat: number winnerSeat: number
log?: LogEntry[] log?: LogEntry[]
@@ -148,6 +165,8 @@ export type ClientMessage =
| { type: 'trade'; cards: string[] } | { type: 'trade'; cards: string[] }
| { type: 'tradeChoose'; pick: number } | { type: 'tradeChoose'; pick: number }
| { type: 'revealChoose'; card: string } | { type: 'revealChoose'; card: string }
| { type: 'sacrificeChoose'; card: string }
| { type: 'peek' }
| { type: 'pass' } | { type: 'pass' }
| { type: 'arrange'; order: string[] } | { type: 'arrange'; order: string[] }
| { type: 'ready' } | { type: 'ready' }