Initial pass at unicorn pack.
This commit is contained in:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '🍎',
|
||||||
|
|||||||
@@ -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
@@ -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' }
|
||||||
|
|||||||
Reference in New Issue
Block a user