From a1d57fe1dd790558eaf32984d3a2cc6881358e45 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Sat, 25 Jul 2026 00:37:44 -0400 Subject: [PATCH] Initial pass at unicorn pack. --- README.md | 35 +- internal/ai/ai.go | 33 +- internal/ai/ai_test.go | 16 + internal/ai/shop.go | 47 ++ internal/game/battle.go | 509 +++++++++++++++- internal/game/cards.go | 443 +++++++++++++- internal/game/game.go | 189 +++++- internal/game/log.go | 3 + internal/game/packs.go | 6 +- internal/game/unicorn_test.go | 939 +++++++++++++++++++++++++++++ internal/game/view.go | 22 +- internal/server/bots.go | 5 + internal/server/ws.go | 4 + web/src/components/BattlePhase.tsx | 62 +- web/src/components/CardView.tsx | 8 +- web/src/components/EventLog.tsx | 3 + web/src/components/ShopPhase.tsx | 49 ++ web/src/components/Table.tsx | 3 + web/src/petArt.ts | 21 + web/src/styles.css | 45 ++ web/src/types.ts | 21 +- 21 files changed, 2419 insertions(+), 44 deletions(-) create mode 100644 internal/game/unicorn_test.go diff --git a/README.md b/README.md index b05e9fa..2631fdc 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,8 @@ Most trophies after round 6 wins. ## Packs -Two card packs are playable, chosen by the host in the lobby (a third, -Unicorn, is stubbed for later). Each pack is six tiers, one per round, and -every pet ships as two copies. +Three card packs are playable, chosen by the host in the lobby. Each pack is +six tiers, one per round, and every pet ships as two copies. ### 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, 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 ``` diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 2488ef6..5b995cd 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -35,12 +35,12 @@ import ( // Action is one move the bot wants to make, mirroring the client protocol. 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 Cards []string // sell / trade Pick int // tradeChoose 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. @@ -62,6 +62,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action { me := &v.Players[v.YouSeat] switch v.Phase { 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.PlayerID == me.ID { return b.decideReveal(v) @@ -107,6 +113,26 @@ func (b *Bot) decideReveal(v *game.View) *Action { 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 // server uses it to decide when to schedule a bot move. func Pending(v *game.View) bool { @@ -116,6 +142,9 @@ func Pending(v *game.View) bool { me := &v.Players[v.YouSeat] switch v.Phase { case game.PhaseShop: + if v.PendingSacrifice != nil { + return v.PendingSacrifice.PlayerID == me.ID + } if v.PendingReveal != nil { return v.PendingReveal.PlayerID == me.ID } diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 6ae9199..10b1197 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -102,6 +102,8 @@ func applyAction(g *game.Game, playerID string, a *Action) error { return g.TradeChoose(playerID, a.Pick) case "revealChoose": return g.RevealChoose(playerID, a.CardID) + case "sacrificeChoose": + return g.SacrificeChoose(playerID, a.CardID) case "pass": return g.Pass(playerID) 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 // the opponent's real deck after known public actions. The model may only // contain information a human spectator would have. diff --git a/internal/ai/shop.go b/internal/ai/shop.go index 45be44e..006b0e5 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -52,6 +52,20 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g for range best { 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: apples := 0 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 { 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 diff --git a/internal/game/battle.go b/internal/game/battle.go index d78abbe..a255e9e 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -20,6 +20,12 @@ type BattleUnit struct { // hitPrevent are this pet's own one-shot partial damage preventions // (Potato perk: two charges of 2). Consumed before any side-level charge. hitPrevent []int + // Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the + // damage it deals in a clash (min 0); Exposed raises the damage it takes on + // each hit. bakuGuard, when set, discards the first Ailment it would gain. + Spooked int `json:"spooked,omitempty"` + Exposed int `json:"exposed,omitempty"` + bakuGuard bool } // shieldCharge is one full-hit block a pet carries. source is the granting @@ -51,6 +57,25 @@ type preventInfo struct { func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } +// hasAilment reports whether the pet carries any Ailment (Mothman's condition). +func (u *BattleUnit) hasAilment() bool { return u.Spooked > 0 || u.Exposed > 0 } + +// takeAilment attaches one Ailment of the given kind, unless Baku's guard eats +// it first. It reports whether the ailment actually landed. +func (u *BattleUnit) takeAilment(kind string) bool { + if u.bakuGuard { + u.bakuGuard = false + return false + } + switch kind { + case AilmentSpooked: + u.Spooked++ + case AilmentExposed: + u.Exposed++ + } + return true +} + // activePerk returns the perk this unit benefits from: only the // last-applied perk counts when several are attached. func (u *BattleUnit) activePerk() *Card { @@ -124,6 +149,11 @@ type BattleEvent struct { // Turkey, Mammoth). // "release": a set-aside Card left Seat's zone, its effect consumed // (matched by Card.ID). + // "mana": Seat's Mana pool changed by Count (+gain / −spend) (Unicorn). + // "ailment": Seat's pet gained Count Ailment cards (Card names the kind) + // (Unicorn: Barghest, Basilisk, Frost Wolf, …). + // "bounce": Seat's pet sent Target's pet (Card) to the bottom of Target's + // deck (Unicorn: Loveland Frogman). Type string `json:"type"` // Seat/Target must NOT be omitempty: 0 is a valid seat (the first // player) and dropping it makes the client read sides[undefined]. @@ -165,6 +195,11 @@ type BattleResult struct { // decisive the result was — the margin the AI uses to prefer a lineup that // fights harder, even in a battle it can't win. Survivors []int `json:"survivors,omitempty"` + // ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the + // battle ends; finalizeBattle writes it back to the players. NextRoundApples + // is apples each seat banked for next round's hand (Skeleton Dog). + ManaAfter []int `json:"manaAfter,omitempty"` + NextRoundApples []int `json:"nextRoundApples,omitempty"` } // setAsideRocks is a fainted pet's pending rock payout. @@ -214,6 +249,19 @@ type battleSide struct { feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played petsPlayed int // pets fielded so far (Komodo's "first pet") retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits + + // --- Unicorn pack --- + mana int // persistent Mana pool, seeded from the player and written back + pendingAilments []Card // ailments on top of the deck, waiting for the next pet + negators []Card // Mandrake set-asides: each negates the next enemy Faint ability + nextRoundApples int // apples banked for next round's hand (Skeleton Dog) + + // --- Unicorn pack, tiers 4-6 --- + unicornGuards []Card // Unicorn: each turns the next incoming friendly ailment into 2 apples + smallPetBonus int // Rootlin: +power for friendly pets with base Power <= 2 + fairyGuards []Card // Fairy: each recycles the next friendly faint to the deck bottom + ailmentBoost int // Manticore: enemy pets' Ailments count for this much more + manaFeed []feedAside // Team Spirit: each pet played, spend 1 Mana to feed apples } // hasPetInStack reports whether any pet remains face-down in the stack. @@ -324,6 +372,14 @@ func (g *Game) finalizeBattle(res *BattleResult) { for _, p := range g.Players { p.PendingApplesInPlay = 0 p.PendingTrumpets = 0 + // Unicorn pack: persist the Mana pool as it stood at battle's end, and + // bank any apples destined for next round's hand (Skeleton Dog). + if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) { + p.Mana = res.ManaAfter[p.Seat] + } + if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) { + p.NextRoundApples += res.NextRoundApples[p.Seat] + } } } @@ -339,6 +395,9 @@ func (g *Game) runBattle() *BattleResult { for _, p := range g.Players { s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} + // Unicorn pack: the persistent Mana pool comes into battle (read-only + // here; written back by finalizeBattle so re-runs stay deterministic). + s.mana = p.Mana sides[p.Seat] = s res.StackSizes[p.Seat] = len(p.Deck) res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) @@ -418,21 +477,104 @@ func (g *Game) runBattle() *BattleResult { emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n, Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))}) } - // spend pays an effect's Trumpet cost from a side's pool, narrating the - // spend. Returns false (without paying) when the side can't afford it, so - // the caller skips the effect. A zero-cost effect always "pays". - spend := func(seat int, e Effect, cause string) bool { - if e.CostTrumpet <= 0 { - return true + // gainMana adds Mana to a side's persistent pool and narrates it. + gainMana := func(seat, n int, cause string) { + if n <= 0 { + return } - if sides[seat].trumpets < e.CostTrumpet { + sides[seat].mana += n + emit(BattleEvent{Type: "mana", Seat: seat, Count: n, + Text: fmt.Sprintf("%s gains %d Mana (now %d).", cause, n, sides[seat].mana)}) + } + // spend pays an effect's Trumpet and Mana costs from a side's pools, + // narrating each. Returns false (paying nothing) when the side can't afford + // the full cost, so the caller skips the effect. A cost-free effect always + // "pays". + spend := func(seat int, e Effect, cause string) bool { + s := sides[seat] + if s.trumpets < e.CostTrumpet || s.mana < e.CostMana { return false } - sides[seat].trumpets -= e.CostTrumpet - emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet, - Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))}) + if e.CostTrumpet > 0 { + s.trumpets -= e.CostTrumpet + emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet, + Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))}) + } + if e.CostMana > 0 { + s.mana -= e.CostMana + emit(BattleEvent{Type: "mana", Seat: seat, Count: -e.CostMana, + Text: fmt.Sprintf("%s spends %d Mana.", cause, e.CostMana)}) + } return true } + // unicornConvert consumes one of side `s`'s Unicorn guards, if any, turning + // an incoming ailment into 2 Apples on top of that side's deck. Returns true + // when a guard fired (so the caller skips applying the ailment). + unicornConvert := func(seat int) bool { + s := sides[seat] + if len(s.unicornGuards) == 0 { + return false + } + card := s.unicornGuards[len(s.unicornGuards)-1] + s.unicornGuards = s.unicornGuards[:len(s.unicornGuards)-1] + emit(BattleEvent{Type: "release", Seat: seat, Card: &card, + Text: fmt.Sprintf("%s's Unicorn turns an Ailment into 2 Apples.", pname(seat))}) + for range 2 { + apple := g.newApple() + s.stack = append([]Card{apple}, s.stack...) + emit(BattleEvent{Type: "summon", Seat: seat, Card: &apple, + Text: fmt.Sprintf("%s adds an apple on top of %s's deck.", pname(seat), pname(seat))}) + } + return true + } + // addAilment (Unicorn pack) afflicts the enemy of `from`. toDeck drops the + // ailment cards on top of the enemy deck (they snare the next pet revealed); + // otherwise they attach to the enemy pet in play now. A friendly Unicorn + // guard converts each incoming ailment to 2 apples; Baku eats one on a pet. + addAilment := func(from int, kind string, count int, toDeck bool, cause string) { + if count <= 0 { + return + } + target := (from + 1) % n + ts := sides[target] + if toDeck { + for range count { + if unicornConvert(target) { + continue + } + a := g.newAilment(kind) + ts.stack = append([]Card{a}, ts.stack...) + emit(BattleEvent{Type: "summon", Seat: target, Card: &a, + Text: fmt.Sprintf("%s adds %s on top of %s's deck.", cause, a.Name, pname(target))}) + } + return + } + if ts.unit == nil { + return + } + rep := g.newAilment(kind) + landed, guarded := 0, 0 + for range count { + if unicornConvert(target) { + continue + } + if ts.unit.takeAilment(kind) { + landed++ + } else { + guarded++ + } + } + if landed == 0 && guarded == 0 { + return // every ailment was converted to apples; nothing to narrate here + } + txt := fmt.Sprintf("%s afflicts %s with %d %s.", cause, ts.unit.Card.Name, landed, rep.Name) + if landed == 0 { + txt = fmt.Sprintf("%s shrugs off the %s.", ts.unit.Card.Name, rep.Name) + } else if guarded > 0 { + txt = fmt.Sprintf("%s afflicts %s with %d %s (one shrugged off).", cause, ts.unit.Card.Name, landed, rep.Name) + } + emit(BattleEvent{Type: "ailment", Seat: target, Card: &rep, Count: landed, Text: txt}) + } // allowed gates battle-time effects on their conditions. allowed := func(e Effect, u *BattleUnit) bool { @@ -444,10 +586,22 @@ func (g *Game) runBattle() *BattleResult { return u != nil && u.activePerk() != nil case ConditionTripled: return false // shop-time condition; never true in battle + case ConditionEvenRound: + return g.Round%2 == 0 } return true } + // effAilment returns a seat's pet's effective Spooked/Exposed values, + // including any boost from an enemy Manticore set aside on the other side. + // A pet with none of that ailment gets no boost. (Unicorn pack.) + effAilment := func(seat, base int) int { + if base == 0 { + return 0 + } + return base + sides[(seat+1)%n].ailmentBoost + } + // hitUnit applies one attack against a seat's pet: shields block the // whole hit, Garlic shaves per-attack damage. Returns the damage dealt and, // when a shield absorbed the hit, a shieldBlock describing it (nil @@ -458,6 +612,9 @@ func (g *Game) runBattle() *BattleResult { if u == nil || amount <= 0 { return 0, nil, nil } + // Unicorn pack: Exposed raises the incoming damage of a landed hit. A + // shield still blocks the whole (raised) hit; Garlic/preventions shave it. + amount += effAilment(seat, u.Exposed) if sides[seat].shields > 0 { sides[seat].shields-- var card *Card @@ -539,6 +696,10 @@ func (g *Game) runBattle() *BattleResult { if isBee(u.Card) { s.beesFainted++ } + // Unicorn pack: a Fairy set aside BEFORE this faint recycles the fallen + // pet to the bottom of the deck. Snapshot the count now so a Fairy arming + // on THIS faint doesn't recycle itself. + fairyBefore := len(s.fairyGuards) // Track distinct suits among friendly fainted pets (Honduran White // Bat). Bees and the Golden Retriever have no suit. if !isBee(u.Card) && u.Card.Suit != "" { @@ -551,6 +712,25 @@ func (g *Game) runBattle() *BattleResult { emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c, Text: fmt.Sprintf("%s is set aside.", c.Name)}) } + // Unicorn pack: a set-aside Mandrake on the enemy side cancels this + // pet's Faint ability (the pet still faints; its effects just don't run). + if es := enemyOf(seat); len(es.negators) > 0 { + hasFaint := false + for _, e := range u.effects() { + if e.Trigger == TriggerFaint { + hasFaint = true + break + } + } + if hasFaint { + m := es.negators[len(es.negators)-1] + es.negators = es.negators[:len(es.negators)-1] + emit(BattleEvent{Type: "release", Seat: (seat + 1) % n, Card: &m, + Text: fmt.Sprintf("%s's Mandrake cancels %s's faint ability.", pname((seat+1)%n), u.Card.Name)}) + goto enemyReactions + } + } + { cause := fmt.Sprintf("%s's faint effect", u.Card.Name) for _, e := range u.effects() { if e.Trigger != TriggerFaint || !allowed(e, u) { @@ -643,8 +823,79 @@ func (g *Game) runBattle() *BattleResult { case ActionPetAura: s.petBonus += e.count() setAside() + case ActionGainMana: + gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause) + case ActionAddAilment: + addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause) + case ActionNextRoundApple: + // Banked for next round's hand; surfaced then in the shop log + // rather than as a battle-board change now. + s.nextRoundApples += e.count() + case ActionNegateEnemyFaint: + s.negators = append(s.negators, u.Card) + setAside() + case ActionReviveSelf: + // Slime: put a plain copy back on top of the deck — no faint + // ability, so it can't loop. "Once per round" falls out of that. + revived := u.Card + revived.ID = g.newCardID() + revived.Effects = nil + revived.EffectText = "" + summon(seat, revived, cause) + case ActionAilmentToApples: + s.unicornGuards = append(s.unicornGuards, u.Card) + setAside() + case ActionSmallPetAura: + s.smallPetBonus += e.count() + setAside() + case ActionAilmentBoost: + s.ailmentBoost += e.count() + setAside() + case ActionManaFeedOnPlay: + s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card}) + setAside() + case ActionReviveNextFaint: + s.fairyGuards = append(s.fairyGuards, u.Card) + setAside() + case ActionSummonFromDiscard: + // Chimera: add Count random cards from the FromTier discard pile as + // temporary copies on top of the deck. + pile := g.Discards[e.FromTier] + for range effectCount(e, s, u, enemyOf(seat)) { + if len(pile) == 0 { + break + } + pick := pile[g.battleDraw(len(pile))] + copyC := pick + copyC.ID = g.newCardID() + copyC.Temporary = true + summon(seat, copyC, cause) + } + case ActionSummonFromTierDeck: + // Pixiu: a temporary copy of the top of the FromTier shop deck. + if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) { + deck := g.ShopDecks[e.FromTier-1] + if len(deck) > 0 { + copyC := deck[0] + copyC.ID = g.newCardID() + copyC.Temporary = true + summon(seat, copyC, cause) + } + } } } + } + enemyReactions: + // Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck + // bottom (a fresh copy, so it re-enters later and can faint again). + if fairyBefore > 0 { + s.fairyGuards = s.fairyGuards[:len(s.fairyGuards)-1] + revived := u.Card + revived.ID = g.newCardID() + s.stack = append(s.stack, revived) + emit(BattleEvent{Type: "summon", Seat: seat, Card: &revived, + Text: fmt.Sprintf("A set-aside Fairy sends %s to the bottom of %s's deck.", u.Card.Name, pname(seat))}) + } // Enemy Faints triggers on surviving pets elsewhere (Hippo). for other, os := range sides { if other == seat || os.unit == nil || !os.unit.Alive() { @@ -699,20 +950,32 @@ func (g *Game) runBattle() *BattleResult { for range e.count() { u.Shields = append(u.Shields, shieldCharge{}) } + case ActionHeal: + // Unicorn pack: Health Potion perk removes damage markers. + healed := min(e.count(), u.Damage) + if healed > 0 { + u.Damage -= healed + emit(BattleEvent{Type: "heal", Seat: seat, DamageAfter: u.Damage, + Text: fmt.Sprintf("%s heals %d after being hurt.", u.Card.Name, healed)}) + } } } } // afterAttack fires After-Attack effects on a pet that survived a clash it - // fought in (Bulldog eats an apple). Effects here are once per battle. + // fought in (Bulldog eats an apple once per battle; Behemoth every clash). afterAttack := func(seat int, u *BattleUnit) { - if !u.Alive() || u.afterAttackUsed { + if !u.Alive() { return } for _, e := range u.effects() { if e.Trigger != TriggerAfterAttack || !allowed(e, u) { continue } + // Bulldog's charge is once per battle; Behemoth's fires every clash. + if e.Once && u.afterAttackUsed { + continue + } if !spend(seat, e, u.Card.Name) { continue } @@ -725,24 +988,30 @@ func (g *Game) runBattle() *BattleResult { emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)}) } - u.afterAttackUsed = true + if e.Once { + u.afterAttackUsed = true + } } } // throwRocks rolls `dice` rock dice against one seat's pet. Rocks are - // not "attacks with" the pet, so no knockout applies. Reports a kill. - throwRocks := func(from, target, dice int, source *Card) (killed bool) { + // not "attacks with" the pet, so no knockout applies. Reports a kill and how + // many dice came up blank (a 0), for Tatzelwurm's mana payout. + throwRocks := func(from, target, dice int, source *Card) (killed bool, blanks int, dealt int) { tu := sides[target].unit if tu == nil || dice <= 0 { // No target, or a Per-scaled volley that came out to zero (Royal // Flycatcher / Grizzly with no fainted pets): nothing to animate. - return false + return false, 0, 0 } roll := 0 faces := make([]int, dice) for i := range dice { faces[i] = g.rollRockDie() roll += faces[i] + if faces[i] == 0 { + blanks++ + } } dealt, block, prev := hitUnit(target, roll) died := !tu.Alive() @@ -790,12 +1059,12 @@ func (g *Game) runBattle() *BattleResult { } faint(target, tu) sides[target].unit = nil - return true + return true, blanks, dealt } if dealt > 0 { hurt(target, tu) } - return false + return false, blanks, dealt } // nextTarget finds the seat whose pet a standard enemy-directed play @@ -837,12 +1106,52 @@ func (g *Game) runBattle() *BattleResult { s.pending = append(s.pending, c) continue } + if c.IsAilment() { + // Unicorn pack: an ailment on top of the deck (Frost Wolf, Slime) + // waits to attach to the next pet revealed on this side. + emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, + Text: fmt.Sprintf("%s's %s waits for the next pet.", pname(seat), c.Name)}) + s.pendingAilments = append(s.pendingAilments, c) + continue + } + // Unicorn pack: Sleipnir's base Power becomes the owner's Mana + // (capped) at the moment it is played. + for _, e := range c.Effects { + if e.Trigger == TriggerPassive && e.Action == ActionManaPower { + c.Power = s.mana + if e.Cap > 0 && c.Power > e.Cap { + c.Power = e.Cap + } + } + } u := &BattleUnit{Card: c, Foods: s.pending} u.Bonus += appleCount(s.pending) u.Bonus += s.petBonus if isBee(c) { u.Bonus += s.beeBonus } + // Unicorn pack: Rootlin buffs small pets (base Power <= 2). + if c.Power <= 2 { + u.Bonus += s.smallPetBonus + } + // Unicorn pack: Baku discards the first ailment it would take. + for _, e := range c.Effects { + if e.Trigger == TriggerPassive && e.Action == ActionAilmentImmune { + u.bakuGuard = true + } + } + // Ailments waiting on this deck attach now, respecting Baku's + // guard; each landing is narrated so the client updates its badges. + for _, a := range s.pendingAilments { + landed := 0 + if u.takeAilment(a.Ailment) { + landed = 1 + } + ac := a + emit(BattleEvent{Type: "ailment", Seat: seat, Card: &ac, Count: landed, + Text: fmt.Sprintf("%s's %s latches onto %s.", pname(seat), a.Name, c.Name)}) + } + s.pendingAilments = nil // Pets carry their starting bonus (foods + auras) in the // reveal event so clients can display it directly. revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name) @@ -892,6 +1201,23 @@ func (g *Game) runBattle() *BattleResult { emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)}) } + // Unicorn pack: Team Spirit spends 1 Mana per set-aside to feed the + // just-played pet its apples (mandatory when affordable). + for i := range s.manaFeed { + if s.mana <= 0 { + break + } + s.mana-- + src := s.manaFeed[i].src + emit(BattleEvent{Type: "mana", Seat: seat, Count: -1, + Text: fmt.Sprintf("%s spends 1 Mana.", src.Name)}) + for range s.manaFeed[i].apples { + u.Foods = append(u.Foods, g.newApple()) + u.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, + Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.manaFeed[i].apples, u.Bonus)}) + } // Walk the pet's own play effects, then its active perk's, so a // play-time shield knows its source (an innate pet block vs a // Melon perk that should be shown and later dropped). @@ -1020,18 +1346,18 @@ func (g *Game) runBattle() *BattleResult { switch { case q.everyone: for seat := range sides { - if throwRocks(q.seat, seat, dice, q.source) { + if killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed { anyDeath = true } } case q.effect.Target == "self": // Manatee pelts its own pet. - if throwRocks(q.seat, q.seat, dice, q.source) { + if killed, _, _ := throwRocks(q.seat, q.seat, dice, q.source); killed { anyDeath = true } default: if t := nextTarget(q.seat); t >= 0 { - if throwRocks(q.seat, t, dice, q.source) { + if killed, _, _ := throwRocks(q.seat, t, dice, q.source); killed { anyDeath = true } } @@ -1039,6 +1365,29 @@ func (g *Game) runBattle() *BattleResult { if q.release != nil { emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release}) } + case ActionThrowRockGainMana: + // Tatzelwurm: throw the rocks, then gain 1 Mana per blank rolled. + dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) + if t := nextTarget(q.seat); t >= 0 { + killed, blanks, _ := throwRocks(q.seat, t, dice, q.source) + if killed { + anyDeath = true + } + gainMana(q.seat, blanks, costName) + } + case ActionGainMana: + gainMana(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName) + case ActionAddAilment: + addAilment(q.seat, q.effect.Ailment, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), q.effect.Target == "enemyDeck", costName) + // Calygreyhound also eats apples alongside the spook. + if q.effect.Eat > 0 && q.unit != nil { + for range q.effect.Eat { + q.unit.Foods = append(q.unit.Foods, g.newApple()) + q.unit.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus, + Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)}) + } case ActionGainTrumpet: gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName) case ActionDoubleTrumpets: @@ -1159,6 +1508,13 @@ func (g *Game) runBattle() *BattleResult { summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name)) } case ActionEatApple: + // Unicorn pack: Mothman only eats if the enemy pet is ailing. + if q.effect.Condition == ConditionEnemyAilment { + et := nextTarget(q.seat) + if et < 0 || sides[et].unit == nil || !sides[et].unit.hasAilment() { + continue + } + } count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) if count > 0 { for range count { @@ -1193,11 +1549,111 @@ func (g *Game) runBattle() *BattleResult { emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice, Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))}) if t := nextTarget(q.seat); t >= 0 { - if throwRocks(q.seat, t, 2*choice, nil) { + if killed, _, _ := throwRocks(q.seat, t, 2*choice, nil); killed { anyDeath = true } } } + case ActionRockThenEat: + // Vampire Bat: (if the enemy is ailing) throw rocks, then eat apples + // equal to the damage those rocks actually dealt (Exposed counts). + t := nextTarget(q.seat) + if t < 0 { + continue + } + if q.effect.Condition == ConditionEnemyAilment && !sides[t].unit.hasAilment() { + continue + } + dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) + killed, _, dealt := throwRocks(q.seat, t, dice, nil) + if killed { + anyDeath = true + } + if dealt > 0 && sides[q.seat].unit == q.unit { + for range dealt { + q.unit.Foods = append(q.unit.Foods, g.newApple()) + q.unit.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus, + Text: fmt.Sprintf("%s eats %d apple%s from the damage dealt (now +%d).", q.unit.Card.Name, dealt, plural(dealt), q.unit.Bonus)}) + } + case ActionBounceEnemy: + // Loveland Frogman: send the enemy pet to the bottom of the enemy + // deck as a fresh body with its Play ability stripped (so two of them + // can't bounce each other forever). + t := nextTarget(q.seat) + if t < 0 { + continue + } + tu := sides[t].unit + bounced := tu.Card + bounced.ID = g.newCardID() + var keep []Effect + for _, ef := range bounced.Effects { + if ef.Trigger != TriggerPlay { + keep = append(keep, ef) + } + } + bounced.Effects = keep + sides[t].stack = append(sides[t].stack, bounced) + sides[t].unit = nil + emit(BattleEvent{Type: "bounce", Seat: q.seat, Target: t, Card: &bounced, + Text: fmt.Sprintf("%s sends %s to the bottom of %s's deck.", q.unit.Card.Name, tu.Card.Name, pname(t))}) + anyDeath = true // force a refill before the clash + case ActionSpendManaRocks: + // Sea Serpent: spend all Mana to throw that many rocks. + s := sides[q.seat] + choice := s.mana + if choice > 0 { + s.mana -= choice + emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice, + Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)}) + if t := nextTarget(q.seat); t >= 0 { + if killed, _, _ := throwRocks(q.seat, t, choice, nil); killed { + anyDeath = true + } + } + } + case ActionSpendManaSpook: + // Bakunawa: spend all Mana to add that many Spooked to the enemy pet. + s := sides[q.seat] + choice := s.mana + if choice > 0 { + s.mana -= choice + emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice, + Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)}) + addAilment(q.seat, AilmentSpooked, choice, false, costName) + } + case ActionGainAbility: + // Abomination: copy a random pet's effects from the highest tier + // discard pile. Gained abilities fire on their natural triggers going + // forward; a gained Play ability doesn't retro-fire this turn. + bestTier := 0 + for tier, pile := range g.Discards { + if tier <= bestTier { + continue + } + for _, dc := range pile { + if dc.IsPet() { + bestTier = tier + break + } + } + } + if bestTier > 0 { + var pets []Card + for _, dc := range g.Discards[bestTier] { + if dc.IsPet() { + pets = append(pets, dc) + } + } + if len(pets) > 0 { + pick := pets[g.battleDraw(len(pets))] + q.unit.Card.Effects = append(append([]Effect(nil), q.unit.Card.Effects...), pick.Effects...) + emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus, + Text: fmt.Sprintf("%s mimics %s's ability.", q.unit.Card.Name, pick.Name)}) + } + } } } // Battle over? A side that can no longer field a pet is out (checked @@ -1219,7 +1675,10 @@ func (g *Game) runBattle() *BattleResult { // Clash. Two-player for now; >2-player battle pairings come later // (the surrounding state is already per-seat). ua, ub := sides[0].unit, sides[1].unit - powA, powB := ua.Power(), ub.Power() + // Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied + // to the defender inside hitUnit); a Manticore boosts enemy ailments. + powA := max(0, ua.Power()-effAilment(0, ua.Spooked)) + powB := max(0, ub.Power()-effAilment(1, ub.Spooked)) dealtA, blockA, prevA := hitUnit(0, powB) dealtB, blockB, prevB := hitUnit(1, powA) // Scorpion: a clash attack that hurts, KOs. @@ -1321,6 +1780,8 @@ func (g *Game) runBattle() *BattleResult { // Record each side's leftover force: a live pet in play plus any pets never // reached in the stack. The loser lands on 0. res.Survivors = make([]int, n) + res.ManaAfter = make([]int, n) + res.NextRoundApples = make([]int, n) for seat, s := range sides { cnt := 0 if s.unit != nil && s.unit.Alive() { @@ -1332,6 +1793,8 @@ func (g *Game) runBattle() *BattleResult { } } res.Survivors[seat] = cnt + res.ManaAfter[seat] = s.mana + res.NextRoundApples[seat] = s.nextRoundApples } return res } diff --git a/internal/game/cards.go b/internal/game/cards.go index 9e6d60d..6918181 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -19,6 +19,9 @@ type CardKind string const ( KindPet CardKind = "pet" KindFood CardKind = "food" + // KindAilment (Unicorn pack) is a temporary debuff card (Spooked, Exposed) + // attached to a pet during battle. It is neither a pet nor a food. + KindAilment CardKind = "ailment" ) // Food identifiers. @@ -37,6 +40,15 @@ const ( FoodPotato = "potato" FoodDurian = "durian" FoodTomato = "tomato" + // FoodFairyDust (Unicorn pack) is a perk food: the pet it's on gains 1 Mana + // when played. FoodWaterOfYouth is a one-shot shop food (not a perk) that + // upgrades a pet into a next-tier card. + FoodFairyDust = "fairyDust" + FoodWaterOfYouth = "waterOfYouth" + // Unicorn pack perk foods, tiers 4-6. + FoodHealthPotion = "healthPotion" // Hurt: heal 3 damage + FoodBigManaPotion = "bigManaPotion" // Play: gain 3 Mana + FoodCornucopia = "cornucopia" // Play: eat 2 Apples, add 2 on deck ) // EffectTrigger is when an effect fires. @@ -209,6 +221,91 @@ const ( // Retriever, when summoned, prevents Count damage on its first hit (German // Shepherd). ActionGuardRetriever EffectAction = "guardRetriever" + + // --- Unicorn pack --- + // ActionGainMana adds Count Mana to the acting side. Mana is a persistent + // resource (unlike Trumpets): it is gained shop-time (Buy: Cuddle Toad, + // Thunderbird) or battle-time (Play/Faint: Alchemedes, Fur-Bearing Trout), + // kept between rounds, and spent via CostMana (see Effect.CostMana). + ActionGainMana EffectAction = "gainMana" + // ActionAddAilment attaches Count Ailment cards (Effect.Ailment: "spooked" | + // "exposed") to the enemy. Target "" hits the enemy pet in play; Target + // "enemyDeck" puts them on top of the enemy deck to snare the next pet. When + // Eat > 0 (Calygreyhound) the pet also eats that many apples. Battle-time. + ActionAddAilment EffectAction = "addAilment" + // ActionAilmentImmune (passive) makes the pet discard the first Ailment it + // would receive (or one it enters play with) — Baku. + ActionAilmentImmune EffectAction = "ailmentImmune" + // ActionThrowRockGainMana (play) throws Count rocks at the enemy pet, then + // gains 1 Mana for each rock that rolled a blank (a 0) — Tatzelwurm. + ActionThrowRockGainMana EffectAction = "throwRockGainMana" + // ActionReviveSelf (faint) puts a plain copy of this pet back on top of its + // owner's deck once per battle (the copy carries no faint ability, so it + // can't loop) — Slime. + ActionReviveSelf EffectAction = "reviveSelf" + // ActionNegateEnemyFaint (faint) sets the pet aside: the next enemy pet to + // faint has its Faint ability do nothing — Mandrake. + ActionNegateEnemyFaint EffectAction = "negateEnemyFaint" + // ActionNextRoundApple (faint) banks Count apples that land in the owner's + // hand at the start of the next round — Skeleton Dog. + ActionNextRoundApple EffectAction = "nextRoundApple" + // ActionPeekShop (passive) marks a pet whose owner may reveal it from hand to + // look at the top of the current shop deck, once per round — Bigfoot. It is + // an optional shop action (see Game.PeekShopDeck), never auto-fired. + ActionPeekShop EffectAction = "peekShop" + // ActionUpgradeNextTier (buy) discards this food and a pet from the player's + // hand to take the top card of the next tier's deck for free — Water of + // Youth. Shop-time; opens a PendingSacrifice choice. + ActionUpgradeNextTier EffectAction = "upgradeNextTier" + + // --- Unicorn pack, tiers 4-6 --- + // ActionAilmentToApples (faint) sets the pet aside and arms a side guard: the + // next Ailment that would land on a friendly pet or friendly deck is replaced + // by 2 Apples on top of the owner's deck — Unicorn. + ActionAilmentToApples EffectAction = "ailmentToApples" + // ActionSmallPetAura (faint) sets the pet aside: friendly pets with base + // Power <= 2 get +Count Power for the rest of the battle — Rootlin. + ActionSmallPetAura EffectAction = "smallPetAura" + // ActionReviveNextFaint (faint) sets the pet aside (paying CostMana): the next + // time a friendly pet faints, a copy goes on the bottom of the owner's deck — + // Fairy. + ActionReviveNextFaint EffectAction = "reviveNextFaint" + // ActionGainAbility (play) reveals a random pet from the highest tier with + // discards and copies its effects onto this pet — Abomination. + ActionGainAbility EffectAction = "gainAbility" + // ActionSummonFromDiscard (faint, paying CostMana) adds Count random cards + // from the FromTier discard pile as temporary copies on top of the deck — + // Chimera. + ActionSummonFromDiscard EffectAction = "summonFromDiscard" + // ActionSummonFromTierDeck (faint, paying CostMana) adds a temporary copy of + // the top of the FromTier shop deck on top of the owner's deck — Pixiu. + ActionSummonFromTierDeck EffectAction = "summonFromTierDeck" + // ActionRockThenEat (play) throws Count rocks at the enemy pet, then feeds + // this pet Apples equal to the damage those rocks actually dealt (Exposed + // counts) — Vampire Bat. Usually gated on ConditionEnemyAilment. + ActionRockThenEat EffectAction = "rockThenEat" + // ActionBounceEnemy (play) sends the enemy pet to the bottom of the enemy + // deck as a fresh body (its Play ability stripped so it can't re-loop) — + // Loveland Frogman. + ActionBounceEnemy EffectAction = "bounceEnemy" + // ActionManaPower (passive) sets the pet's base Power to the owner's Mana at + // play time, capped at Cap — Sleipnir. + ActionManaPower EffectAction = "manaPower" + // ActionSpendManaRocks (play) spends all the owner's Mana to throw that many + // rocks at the enemy pet — Sea Serpent. + ActionSpendManaRocks EffectAction = "spendManaRocks" + // ActionSpendManaSpook (play) spends all the owner's Mana to add that many + // Spooked to the enemy pet — Bakunawa. + ActionSpendManaSpook EffectAction = "spendManaSpook" + // ActionAilmentBoost (faint) sets the pet aside: Ailments on enemy pets count + // for Count more — Manticore. + ActionAilmentBoost EffectAction = "ailmentBoost" + // ActionManaFeedOnPlay (faint) sets the pet aside: each time the owner plays a + // pet, spend 1 Mana to feed it Count Apples — Team Spirit. + ActionManaFeedOnPlay EffectAction = "manaFeedOnPlay" + // ActionRevealTierForApples (buy) opens a reveal of a hand pet at tier <= Cap + // for Count Apples — Quetzalcoatl. Shop-time. + ActionRevealTierForApples EffectAction = "revealTierForApples" ) // Per multipliers for dynamic effect counts. @@ -236,6 +333,19 @@ const ( ConditionTripled = "tripledThisRound" // player Tripled during this round's shop ConditionHasPerk = "hasPerk" // this pet has a perk attached ConditionFirstPet = "firstPet" // this is the first pet the side has played (Komodo) + // ConditionEnemyAilment gates a battle effect on the enemy pet in play + // carrying at least one Ailment (Unicorn pack: Mothman). + ConditionEnemyAilment = "enemyAilment" + // ConditionEvenRound gates a battle effect to even-numbered rounds (Unicorn + // pack: Werewolf). + ConditionEvenRound = "evenRound" +) + +// Ailment kinds (Unicorn pack). Ailments are temporary debuff cards attached to +// a pet during battle; they stack and are removed at the end of the round. +const ( + AilmentSpooked = "spooked" // the pet deals N less damage in a clash (min 0) + AilmentExposed = "exposed" // the pet takes N extra damage each time it is hit ) // Effect is one trigger→action pair printed on a card. @@ -257,6 +367,22 @@ type Effect struct { // effect to fire (Golden pack). It's auto-paid when affordable and the // effect is skipped otherwise — battles take no player input. CostTrumpet int `json:"costTrumpet,omitempty"` + // CostMana, when > 0, is a Mana cost paid the same way as CostTrumpet + // (Unicorn pack). Unlike Trumpets, Mana persists across rounds. + CostMana int `json:"costMana,omitempty"` + // Ailment names the debuff an ActionAddAilment applies ("spooked" | + // "exposed"); Count is how many (Unicorn pack). + Ailment string `json:"ailment,omitempty"` + // Eat, on an ActionAddAilment, also feeds the pet that many apples + // (Calygreyhound spends 1 Mana to eat and spook in one ability). + Eat int `json:"eat,omitempty"` + // FromTier names a tier a battle effect pulls from — a discard pile (Chimera) + // or a shop deck (Pixiu) — 1-based (Unicorn pack). + FromTier int `json:"fromTier,omitempty"` + // Once marks an After-Attack effect that fires at most once per battle + // (Bulldog); without it the effect fires after every clash survived + // (Behemoth). + Once bool `json:"once,omitempty"` // SurviveOnly marks a Hurt effect that fires only when the pet lives through // the hit. By default a Hurt effect still fires on a fatal hit (the Lizard // drops its Bee even when killed); self-buffs that are pointless once the @@ -295,10 +421,14 @@ type Card struct { // Temporary cards (apples, bees) are removed from the deck after the // next battle. Temporary bool `json:"temporary,omitempty"` + // Ailment marks a debuff card (Unicorn pack): "spooked" | "exposed". Set + // together with Kind == KindAilment. + Ailment string `json:"ailment,omitempty"` } -func (c Card) IsPet() bool { return c.Kind == KindPet } -func (c Card) IsFood() bool { return c.Kind == KindFood } +func (c Card) IsPet() bool { return c.Kind == KindPet } +func (c Card) IsFood() bool { return c.Kind == KindFood } +func (c Card) IsAilment() bool { return c.Kind == KindAilment } // petTemplate is the printed definition of a pet. Suits lists the suit of // each physical copy in the tier deck (one card per entry). @@ -613,7 +743,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{ }, { Name: "Bulldog", Power: 2, Suits: []Suit{SuitRed, SuitYellow}, - Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple}}, + Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Once: true}}, EffectText: "After Attacking: if it hasn't fainted, eat 1 Apple (once per round)", }, { @@ -853,6 +983,294 @@ var goldenFoodTiers = [MaxRounds][]foodTemplate{ }, } +// unicornPetTiers defines the Unicorn pack's pets. Tiers 1-3 are printed; +// tiers 4-6 are still being authored. Each pet ships as two copies (one per +// listed suit). The pack adds two mechanics the others don't have: Mana (a +// persistent resource spent to power abilities) and Ailments (Spooked/Exposed +// debuffs attached to enemy pets during battle). +var unicornPetTiers = [MaxRounds][]petTemplate{ + { // Tier 1 + { + Name: "Alchemedes", Power: 1, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}}, + EffectText: "Play: gain 1 Mana", + }, + { + Name: "Cuddle Toad", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana}}, + EffectText: "Buy: gain 1 Mana", + }, + { + Name: "Pengobble", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, CostMana: 1}}, + EffectText: "Play: spend 1 Mana to throw 2 Rocks", + }, + { + Name: "Barghest", Power: 1, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked}}, + EffectText: "Play: add 1 Spooked to the enemy pet", + }, + { + Name: "Basilisk", Power: 1, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed}}, + EffectText: "Play: add 1 Exposed to the enemy pet", + }, + { + Name: "Baku", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPassive, Action: ActionAilmentImmune}}, + EffectText: "Discard the first Ailment added to this pet", + }, + }, + { // Tier 2 + { + Name: "Thunderbird", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana, Count: 2}}, + EffectText: "Buy: gain 2 Mana", + }, + { + Name: "Gargoyle", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2, CostMana: 1}}, + EffectText: "Faint: spend 1 Mana to add 2 Apples on top of your deck", + }, + { + Name: "Frost Wolf", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed}, + {Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"}, + }, + EffectText: "Faint: add 1 Exposed to the enemy pet and 1 on top of the enemy deck", + }, + { + Name: "Mothman", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 2, Condition: ConditionEnemyAilment}}, + EffectText: "Play: if the enemy pet has an Ailment, eat 2 Apples", + }, + { + Name: "Nightcrawler", Power: 1, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2}}, + EffectText: "Play: add 2 Spooked to the enemy pet", + }, + { + Name: "Bigfoot", Power: 3, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPassive, Action: ActionPeekShop}}, + EffectText: "Reveal from your hand to look at the top of the shop deck (once per round)", + }, + }, + { // Tier 3 + { + Name: "Fur-Bearing Trout", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionGainMana}, + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2}, + }, + EffectText: "Faint: gain 1 Mana and add 2 Apples on top of your deck", + }, + { + Name: "Calygreyhound", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Eat: 1, CostMana: 1}}, + EffectText: "Play: spend 1 Mana to eat 1 Apple and add 1 Spooked to the enemy pet", + }, + { + Name: "Tatzelwurm", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRockGainMana, Count: 2}}, + EffectText: "Play: throw 2 Rocks, then gain 1 Mana for each blank rolled", + }, + { + Name: "Skeleton Dog", Power: 3, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"}, + {Trigger: TriggerFaint, Action: ActionNextRoundApple}, + }, + EffectText: "Faint: add 1 Apple on top of your deck and 1 to your hand next round", + }, + { + Name: "Mandrake", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionNegateEnemyFaint}}, + EffectText: "Faint: set aside — the next enemy Faint ability does nothing", + }, + { + Name: "Lucky Cat", Power: 3, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerTriple, Action: ActionGainApple, Count: 3}}, + EffectText: "Triple: add 3 Apples to your hand", + }, + { + Name: "Slime", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionReviveSelf}, + {Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"}, + }, + EffectText: "Faint: put this pet on top of your deck and 1 Exposed on the enemy deck (once per round)", + }, + }, + { // Tier 4 + { + Name: "Roc", Power: 4, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionGainMana}, + {Trigger: TriggerSell, Action: ActionGainMana}, + }, + EffectText: "Play: gain 1 Mana · Sell: gain 1 Mana", + }, + { + Name: "Chimera", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromDiscard, Count: 2, FromTier: 1, CostMana: 4}}, + EffectText: "Faint: spend 4 Mana to add 2 random cards from the tier 1 discard pile on top of your deck", + }, + { + Name: "Kraken", Power: 4, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked}, + {Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Target: "enemyDeck"}, + }, + EffectText: "Play: add 1 Spooked to the enemy pet and 1 on top of the enemy deck", + }, + { + Name: "Unicorn", Power: 4, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentToApples}}, + EffectText: "Faint: set aside — replace the next friendly Ailment with 2 Apples on your deck", + }, + { + Name: "Abomination", Power: 4, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainAbility}}, + EffectText: "Play: reveal a random pet from the highest tier discard pile and gain its ability", + }, + { + Name: "Rootlin", Power: 4, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSmallPetAura, Count: 1}}, + EffectText: "Faint: set aside — your pets with base Power 2 or less have +1 Power", + }, + { + Name: "Fairy", Power: 1, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionReviveNextFaint, CostMana: 3}}, + EffectText: "Faint: spend 3 Mana to set aside — the next friendly faint returns to the bottom of your deck", + }, + }, + { // Tier 5 + { + Name: "Kitsune", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Per: PerFaintedPets}}, + EffectText: "Play: gain 1 Mana for each friendly fainted pet", + }, + { + Name: "Pixiu", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromTierDeck, FromTier: 6, CostMana: 4}}, + EffectText: "Faint: spend 4 Mana to add the top card of the tier 6 deck on top of your deck", + }, + { + Name: "Red Dragon", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2}, + {Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2, Target: "enemyDeck"}, + {Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2}, + }, + EffectText: "Play: add 2 Exposed to the enemy pet and 2 on the enemy deck, then throw 2 Rocks", + }, + { + Name: "Amalgamation", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedPets}, + {Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2}, + }, + EffectText: "Play: eat 1 Apple per friendly fainted pet, then add 2 Spooked to the enemy pet", + }, + { + Name: "Vampire Bat", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionRockThenEat, Count: 2, Condition: ConditionEnemyAilment}}, + EffectText: "Play: if the enemy pet has an Ailment, throw 2 Rocks, then eat Apples equal to the damage dealt", + }, + { + Name: "Werewolf", Power: 2, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 6, Condition: ConditionEvenRound}}, + EffectText: "Play: on an even-numbered round, eat 6 Apples", + }, + { + Name: "Loveland Frogman", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionBounceEnemy}}, + EffectText: "Play: put the enemy pet on the bottom of the enemy deck", + }, + }, + { // Tier 6 + { + Name: "Sleipnir", Power: 0, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPassive, Action: ActionManaPower, Cap: 9}}, + EffectText: "This pet's base Power equals your Mana (max 9)", + }, + { + Name: "Sea Serpent", Power: 4, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaRocks}}, + EffectText: "Play: spend any number of Mana to throw that many Rocks", + }, + { + Name: "Bakunawa", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaSpook}}, + EffectText: "Play: spend any number of Mana to add that many Spooked to the enemy pet", + }, + { + Name: "Manticore", Power: 6, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentBoost, Count: 1}}, + EffectText: "Faint: set aside — Ailments on enemy pets count for 1 more", + }, + { + Name: "Team Spirit", Power: 4, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionManaFeedOnPlay, Count: 2}}, + EffectText: "Faint: set aside — each pet you play, spend 1 Mana to feed it 2 Apples", + }, + { + Name: "Behemoth", Power: 6, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Count: 2}}, + EffectText: "After Attacking: if it hasn't fainted, eat 2 Apples", + }, + { + Name: "Quetzalcoatl", Power: 6, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionRevealTierForApples, Count: 3, Cap: 3}}, + EffectText: "Buy: reveal a tier 3 or lower pet from your hand to gain 3 Apples", + }, + }, +} + +// unicornFoodTiers defines the Unicorn pack's food cards (tiers 1-3 printed). +var unicornFoodTiers = [MaxRounds][]foodTemplate{ + {}, // Tier 1 + { // Tier 2 + { + Name: "Fairy Dust", Food: FoodFairyDust, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}}, + EffectText: "Play: gain 1 Mana", + }, + }, + { // Tier 3 + { + Name: "Water of Youth", Food: FoodWaterOfYouth, Copies: 2, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionUpgradeNextTier}}, + EffectText: "Buy: discard this and a pet to buy the top of the next tier for free", + }, + }, + { // Tier 4 + { + Name: "Health Potion", Food: FoodHealthPotion, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionHeal, Count: 3, SurviveOnly: true}}, + EffectText: "Hurt: if this pet hasn't fainted, heal 3 damage", + }, + }, + { // Tier 5 + { + Name: "Big Mana Potion", Food: FoodBigManaPotion, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Count: 3}}, + EffectText: "Play: gain 3 Mana", + }, + }, + { // Tier 6 + { + Name: "Cornucopia", Food: FoodCornucopia, Copies: 2, Perk: true, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionEatApple, Count: 2}, + {Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 2}, + }, + EffectText: "Play: eat 2 Apples and add 2 Apples on top of your deck", + }, + }, +} + // newCardID mints a unique card ID within the game. func (g *Game) newCardID() string { g.NextCardID++ @@ -866,6 +1284,8 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla switch pack { case "golden": return &goldenPetTiers, &goldenFoodTiers + case "unicorn": + return &unicornPetTiers, &unicornFoodTiers default: // turtle (and the placeholder packs, until they ship) return &petTiers, &foodTiers } @@ -981,6 +1401,23 @@ func (g *Game) newApple() Card { } } +// newAilment mints an ailment debuff card (Unicorn pack). It is temporary — it +// leaves play at the end of the round, like an apple — and belongs to no deck +// until an effect attaches it to a pet or drops it on a deck. +func (g *Game) newAilment(kind string) Card { + name := "Spooked" + if kind == AilmentExposed { + name = "Exposed" + } + return Card{ + ID: g.newCardID(), + Kind: KindAilment, + Name: name, + Ailment: kind, + Temporary: true, + } +} + // newBee mints a bee: a temporary 1-power pet with no effect, summoned by // other pets' effects. It counts as a pet while it exists (foods can attach // to it in battle). diff --git a/internal/game/game.go b/internal/game/game.go index 4ad2434..5f6d8aa 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -66,6 +66,19 @@ type Player struct { // PendingTrumpets (Golden pack: Bird of Paradise) is Trumpets the next // battle starts with in the pool; reset after that battle. PendingTrumpets int `json:"pendingTrumpets,omitempty"` + // Mana (Unicorn pack) is a persistent resource, added to the play area as a + // counter. It is gained shop-time (Cuddle Toad, Thunderbird) and battle-time + // (Alchemedes, Fur-Bearing Trout), spent to power abilities (see + // Effect.CostMana), and — unlike Trumpets — kept between rounds. + Mana int `json:"mana,omitempty"` + // NextRoundApples (Unicorn pack: Skeleton Dog) is apples banked in battle + // that land in the player's hand at the start of the next round, then reset. + NextRoundApples int `json:"nextRoundApples,omitempty"` + // ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used + // Bigfoot's reveal (once per round); ShopPeek is the card they saw — a + // snapshot of the shop deck's top, shown only in that player's own view. + ShopPeekedRound int `json:"shopPeekedRound,omitempty"` + ShopPeek *Card `json:"shopPeek,omitempty"` // IsBot marks a computer-controlled seat. The engine treats bots exactly // like humans; the server drives their actions. BotLevel is the bot's // skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the @@ -105,6 +118,20 @@ type PendingReveal struct { PlayerID string `json:"playerId"` Source string `json:"source"` // the Cockatoo's card id Options []string `json:"options"` // eligible pet card ids in the buyer's deck + // Apples, when > 0, is a fixed apple reward (Unicorn pack: Quetzalcoatl gives + // 3). When 0 the reward is the revealed pet's Power (Golden pack: Cockatoo). + Apples int `json:"apples,omitempty"` +} + +// PendingSacrifice is an in-progress Water of Youth buy (Unicorn pack): the +// buyer must discard one of their pets; that pet and the Water of Youth food +// are consumed to take the top card of the next tier's deck for free. Tier is +// the tier the reward comes from. +type PendingSacrifice struct { + PlayerID string `json:"playerId"` + Source string `json:"source"` // the Water of Youth card id (also discarded) + Tier int `json:"tier"` // 1-based tier the reward is drawn from + Options []string `json:"options"` // eligible pet card ids to sacrifice } // Game is the complete authoritative state. It is a pure state machine: no @@ -120,6 +147,10 @@ type Game struct { Players []*Player `json:"players"` ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 ShopRow []Card `json:"shopRow"` // empty ID = empty slot + // Discards (Unicorn pack) is the pile of real cards that came from the shop + // decks and later left a player's deck (sold, traded, sacrificed), keyed by + // tier. Chimera and Abomination draw from it. Temporary cards never enter. + Discards map[int][]Card `json:"discards,omitempty"` Turn int `json:"turn"` // seat with the current shop turn // PrioritySeat holds the priority token: that seat shops first each round // and wins simultaneity races in battle. Assigned randomly at game start; @@ -130,7 +161,10 @@ type Game struct { // PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks // other shop actions on that seat until resolved, like Pending. PendingReveal *PendingReveal `json:"pendingReveal,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` // most recent battle + // PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack); + // it blocks other shop actions on that seat until resolved, like Pending. + PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` + Battle *BattleResult `json:"battle,omitempty"` // most recent battle NextCardID int `json:"nextCardId"` WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie // Log is the running, human-readable event log shown across every phase. @@ -350,12 +384,22 @@ func (g *Game) startShopRound() { g.Phase = PhaseShop g.Pending = nil g.PendingReveal = nil + g.PendingSacrifice = nil for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false p.TripledThisRound = false p.FirstBuyFree = false p.BuysThisRound = 0 + p.ShopPeek = nil + // Unicorn pack: apples banked in battle (Skeleton Dog) arrive in hand now. + for range p.NextRoundApples { + p.Deck = append(p.Deck, g.newApple()) + } + if p.NextRoundApples > 0 { + g.logf(p.Seat, "🍎", "%s starts the round with %d banked apple%s.", p.Name, p.NextRoundApples, plural(p.NextRoundApples)) + p.NextRoundApples = 0 + } } g.ShopRow = make([]Card, ShopRowSize) for i := range g.ShopRow { @@ -402,6 +446,9 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) { if g.PendingReveal != nil { return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction) } + if g.PendingSacrifice != nil { + return nil, fmt.Errorf("%w: finish your choice first", ErrInvalidAction) + } return p, nil } @@ -459,7 +506,7 @@ func (g *Game) BuyAvocado(playerID string, rowIdx int) error { // advanceAfterBuy hands off the turn after a buy, unless the buy opened a // Cockatoo reveal that the same player must resolve first. func (g *Game) advanceAfterBuy() { - if g.PendingReveal != nil { + if g.PendingReveal != nil || g.PendingSacrifice != nil { return } g.advanceShopTurn() @@ -552,6 +599,22 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { if len(opts) > 0 { g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts} } + case ActionRevealTierForApples: + // Unicorn pack: Quetzalcoatl — reveal a hand pet at tier <= Cap for a + // fixed number of apples. + if g.Phase != PhaseShop { + continue + } + maxTier := e.Cap + var opts []string + for _, dc := range p.Deck { + if dc.IsPet() && dc.ID != c.ID && (maxTier <= 0 || dc.Tier <= maxTier) { + opts = append(opts, dc.ID) + } + } + if len(opts) > 0 { + g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts, Apples: e.count()} + } case ActionApplesInPlay: // Golden pack: apples-in-play banked when sold (Hercules Beetle) or // bought (Bird of Paradise). Monkey's battle-prep version is resolved @@ -612,6 +675,33 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: apples, Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)}) } + case ActionGainMana: + // Unicorn pack: shop-time mana (Cuddle Toad, Thunderbird). Battle-time + // mana is handled by the resolver; skip it here. + if trigger == TriggerBuy || trigger == TriggerSell || trigger == TriggerTriple { + p.Mana += e.count() + g.addLog(LogEntry{Seat: p.Seat, Icon: "🔮", Kind: LogMana, Source: c.ID, Count: e.count(), + Text: fmt.Sprintf("%s gains %d Mana (now %d).", c.Name, e.count(), p.Mana)}) + } + case ActionUpgradeNextTier: + // Unicorn pack: Water of Youth — sacrifice a pet + this food to buy the + // top of the next tier for free. Only a real shop buy opens the choice. + if trigger != TriggerBuy || g.Phase != PhaseShop { + continue + } + nextTier := g.Round + 1 + if nextTier > MaxRounds || len(g.ShopDecks[nextTier-1]) == 0 { + continue // no higher tier to upgrade into; the food is wasted + } + var opts []string + for _, dc := range p.Deck { + if dc.IsPet() { + opts = append(opts, dc.ID) + } + } + if len(opts) > 0 { + g.PendingSacrifice = &PendingSacrifice{PlayerID: p.ID, Source: c.ID, Tier: nextTier, Options: opts} + } } } } @@ -636,6 +726,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error { } for _, c := range sold { p.Deck = append(p.Deck, g.newApple()) + g.discardCard(c) g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Kind: LogSell, Source: c.ID, CardName: c.Name, Spawn: "apple", Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)}) g.applyShopTrigger(p, c, TriggerSell) @@ -711,6 +802,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error { for _, id := range cardIDs { idx := p.cardIndex(id) traded = append(traded, p.Deck[idx]) + g.discardCard(p.Deck[idx]) p.Deck = slices.Delete(p.Deck, idx, idx+1) } p.TripledThisRound = true @@ -785,6 +877,9 @@ func (g *Game) RevealChoose(playerID, cardID string) error { } revealed := p.Deck[idx] n := revealed.Power + if g.PendingReveal.Apples > 0 { + n = g.PendingReveal.Apples // Quetzalcoatl: fixed reward + } for range n { p.Deck = append(p.Deck, g.newApple()) } @@ -797,6 +892,83 @@ func (g *Game) RevealChoose(playerID, cardID string) error { return nil } +// PeekShopDeck resolves Bigfoot's optional reveal (Unicorn pack): on the +// player's shop turn, once per round, they look at the top card of the current +// tier's shop deck. It doesn't spend gold or end the turn — it only stores a +// private snapshot served in that player's own view. +func (g *Game) PeekShopDeck(playerID string) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + if p.ShopPeekedRound == g.Round { + return fmt.Errorf("%w: already peeked this round", ErrInvalidAction) + } + hasBigfoot := false + for _, c := range p.Deck { + for _, e := range c.Effects { + if e.Action == ActionPeekShop { + hasBigfoot = true + } + } + } + if !hasBigfoot { + return fmt.Errorf("%w: no pet can peek the shop deck", ErrInvalidAction) + } + p.ShopPeekedRound = g.Round + deck := g.ShopDecks[g.Round-1] + if len(deck) > 0 { + top := deck[0] + p.ShopPeek = &top + } + g.logf(p.Seat, "👁", "%s reveals a pet to peek at the shop deck.", p.Name) + return nil +} + +// SacrificeChoose resolves a pending Water of Youth choice (Unicorn pack): the +// chosen pet and the Water of Youth food are discarded, and the top of the next +// tier's deck joins the player's deck for free (its Buy effect fires). +func (g *Game) SacrificeChoose(playerID, cardID string) error { + if g.Phase != PhaseShop || g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != playerID { + return fmt.Errorf("%w: no choice waiting on you", ErrInvalidAction) + } + if !slices.Contains(g.PendingSacrifice.Options, cardID) { + return fmt.Errorf("%w: choose one of your pets", ErrInvalidAction) + } + p := g.PlayerByID(playerID) + ps := g.PendingSacrifice + g.PendingSacrifice = nil + // Discard the sacrificed pet and the Water of Youth food. + var sacrificed Card + if idx := p.cardIndex(cardID); idx >= 0 { + sacrificed = p.Deck[idx] + g.discardCard(sacrificed) + p.Deck = slices.Delete(p.Deck, idx, idx+1) + } + if idx := p.cardIndex(ps.Source); idx >= 0 { + g.discardCard(p.Deck[idx]) + p.Deck = slices.Delete(p.Deck, idx, idx+1) + } + top := g.drawFromTier(ps.Tier) + if top.ID == "" { + // Tier emptied out between the buy and the choice; nothing to grant. + g.logf(p.Seat, "⏳", "%s's Water of Youth fizzles — the next tier is empty.", p.Name) + g.advanceShopTurn() + return nil + } + p.Deck = append(p.Deck, top) + if hasBuyEffect(top) { + g.addLog(LogEntry{Seat: p.Seat, Icon: "⏳", Kind: LogBuy, Source: top.ID, CardName: top.Name, + Text: fmt.Sprintf("%s trades %s for %s %s from tier %d — its buy ability triggers.", + p.Name, sacrificed.Name, article(top.Name), top.Name, ps.Tier)}) + } else { + g.logf(p.Seat, "⏳", "%s trades %s for a fresh tier %d pet.", p.Name, sacrificed.Name, ps.Tier) + } + g.applyShopTrigger(p, top, TriggerBuy) + g.advanceAfterBuy() + return nil +} + // DebugGrant drops any card straight into a player's deck during the shop, // free and off-turn — a testing aid gated behind the server's DEBUG flag, not // a normal action. No buy effects fire. @@ -957,6 +1129,19 @@ func (g *Game) finish() { } } +// discardCard records a real (non-temporary) card leaving a player's deck into +// the tier's discard pile (Unicorn pack: Chimera / Abomination source). Apples, +// bees, and ailments never enter the pile. +func (g *Game) discardCard(c Card) { + if c.Temporary || c.Tier <= 0 { + return + } + if g.Discards == nil { + g.Discards = map[int][]Card{} + } + g.Discards[c.Tier] = append(g.Discards[c.Tier], c) +} + func hasDuplicates(ids []string) bool { seen := make(map[string]struct{}, len(ids)) for _, id := range ids { diff --git a/internal/game/log.go b/internal/game/log.go index 3127a35..faa8b05 100644 --- a/internal/game/log.go +++ b/internal/game/log.go @@ -34,6 +34,9 @@ const ( LogSell = "sell" // Seat sold Source/CardName (it became an apple) LogTrade = "trade" // Seat traded in Cards for a next-tier pick LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed + // LogMana (Unicorn pack) tags a public shop-time Mana gain (Cuddle Toad, + // Thunderbird), so observers can track the acting seat's Mana pool. + LogMana = "mana" ) // addLog appends an entry, stamping it with the next sequence number and the diff --git a/internal/game/packs.go b/internal/game/packs.go index 3b930ee..98f407e 100644 --- a/internal/game/packs.go +++ b/internal/game/packs.go @@ -1,9 +1,7 @@ package game // Card packs are the selectable sets of pets and food a game is played with. -// Turtle and Golden ship with full card data; Unicorn is declared here as -// infrastructure (shown but not yet playable) so the lobby, views, and -// deck-building all have a single source of truth to grow into. +// Turtle, Golden, and Unicorn all ship with full six-tier card data. // PackInfo describes one selectable pack. Playable gates whether a lobby may // choose it and start a game with it. @@ -21,7 +19,7 @@ const DefaultPack = "turtle" var Packs = []PackInfo{ {ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true}, {ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: true}, - {ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false}, + {ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: true}, } // packByID looks up a pack, returning false if the id is unknown. diff --git a/internal/game/unicorn_test.go b/internal/game/unicorn_test.go new file mode 100644 index 0000000..d339c2f --- /dev/null +++ b/internal/game/unicorn_test.go @@ -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) + } +} diff --git a/internal/game/view.go b/internal/game/view.go index 6b1c111..f9c28fb 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -22,7 +22,13 @@ type PlayerView struct { BuysThisRound int `json:"buysThisRound,omitempty"` // Trumpets is the count banked for the next battle (Bird of Paradise). // Self-only — shown under the player's own deck. - Trumpets int `json:"trumpets,omitempty"` + Trumpets int `json:"trumpets,omitempty"` + // Mana is the player's persistent Mana pool (Unicorn pack). Public: it's a + // play-area counter derivable from public shop/battle events. + Mana int `json:"mana,omitempty"` + // ShopPeek is the card Bigfoot revealed off the top of the shop deck this + // round (Unicorn pack). Self-only — it's private information. + ShopPeek *Card `json:"shopPeek,omitempty"` Deck []Card `json:"deck,omitempty"` // self only } @@ -56,7 +62,10 @@ type View struct { // see a reveal is in progress; the eligible options are only sent to the // buyer (they name the buyer's own hidden pets). PendingReveal *PendingReveal `json:"pendingReveal,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` + // PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the + // options (the buyer's own pets) are only sent to the buyer. + PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` + Battle *BattleResult `json:"battle,omitempty"` WinnerSeat int `json:"winnerSeat"` // Log is the shared, public event log shown across every phase. Log []LogEntry `json:"log,omitempty"` @@ -102,6 +111,7 @@ func (g *Game) ViewFor(playerID string) View { DeckSize: len(p.Deck), PetCount: p.PetCount(), Avocados: p.Avocados, + Mana: p.Mana, } if p.ID == playerID { v.YouSeat = p.Seat @@ -109,6 +119,7 @@ func (g *Game) ViewFor(playerID string) View { pv.FirstBuyFree = p.FirstBuyFree pv.BuysThisRound = p.BuysThisRound pv.Trumpets = p.PendingTrumpets + pv.ShopPeek = p.ShopPeek } v.Players = append(v.Players, pv) } @@ -126,6 +137,13 @@ func (g *Game) ViewFor(playerID string) View { } v.PendingReveal = &reveal } + if g.PendingSacrifice != nil { + sac := *g.PendingSacrifice + if sac.PlayerID != playerID { + sac.Options = nil // hide which of the buyer's pets are eligible + } + v.PendingSacrifice = &sac + } // Battle results (lineups, events) are public once resolved. Keep the // battle around during the following shop phase too, so late joiners / // reconnects can still see the last result. diff --git a/internal/server/bots.go b/internal/server/bots.go index 2ca67f4..4d584af 100644 --- a/internal/server/bots.go +++ b/internal/server/bots.go @@ -161,6 +161,8 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error { return g.TradeChoose(playerID, a.Pick) case "revealChoose": return g.RevealChoose(playerID, a.CardID) + case "sacrificeChoose": + return g.SacrificeChoose(playerID, a.CardID) case "pass": return g.Pass(playerID) case "arrange": @@ -182,6 +184,9 @@ func botFallback(g *game.Game, playerID string) error { } switch g.Phase { 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 { return g.RevealChoose(playerID, g.PendingReveal.Options[0]) } diff --git a/internal/server/ws.go b/internal/server/ws.go index 9efdc1a..2f73480 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -151,6 +151,10 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { err = g.TradeChoose(c.playerID, msg.Pick) case "revealChoose": 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": err = g.Pass(c.playerID) case "arrange": diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 146e37c..45a2119 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -23,6 +23,8 @@ interface UnitVis { bonus: number damage: number 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 { @@ -54,6 +56,9 @@ const EVENT_MS: Record = { release: 500, trumpet: 800, prevent: 900, + mana: 800, + ailment: 900, + bounce: 1000, } 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, damage: 0, dying: false, + spooked: 0, + exposed: 0, } break } s.stack-- 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) } else { s.unit = { @@ -118,6 +127,8 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side bonus: ev.bonus ?? appleCount(s.pending), damage: 0, dying: false, + spooked: 0, + exposed: 0, } s.pending = [] } @@ -187,6 +198,24 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side break // pure animation; no state change case 'trumpet': 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': { // Cone Snail shaved damage off the hit; the reduced total rides along. const u = sides[ev.seat!].unit @@ -228,6 +257,14 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st case 'trumpet': if (ev.seat !== seat) return null 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': return ev.seat === seat ? '🍎' : null case 'heal': @@ -337,7 +374,8 @@ export function BattlePhase({ view, send, step, setStep }: Props) { !done && lastEvent?.seat === seat && (lastEvent.type === 'prep' || - (lastEvent.type === 'reveal' && lastEvent.card?.kind === 'food')) + (lastEvent.type === 'reveal' && + (lastEvent.card?.kind === 'food' || lastEvent.card?.kind === 'ailment'))) ? lastEvent.card?.id : null // 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} dead={s.unit.dying} /> + {(s.unit.spooked > 0 || s.unit.exposed > 0) && ( +
+ {s.unit.spooked > 0 && ( + + 👻{s.unit.spooked > 1 ? s.unit.spooked : ''} + + )} + {s.unit.exposed > 0 && ( + + 🎯{s.unit.exposed > 1 ? s.unit.exposed : ''} + + )} +
+ )} {pop && (
{pop} diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 7b4ec83..cff2c48 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -180,7 +180,7 @@ export function CardView({ const classes = [ 'card', `card-${size}`, - card.kind === 'food' ? 'card-food' : 'card-pet', + card.kind === 'ailment' ? 'card-ailment' : card.kind === 'food' ? 'card-food' : 'card-pet', selected ? 'is-selected' : '', disabled ? 'is-disabled' : '', dead ? 'is-dead' : '', @@ -228,7 +228,11 @@ export function CardView({ ? renderEffect(card.effectText) : card.kind === 'food' && card.food === 'apple' ? '+1 power (this battle)' - : ''} + : card.ailment === 'spooked' + ? 'Deals 1 less damage' + : card.ailment === 'exposed' + ? 'Takes 1 extra damage' + : ''}
{selected &&
} diff --git a/web/src/components/EventLog.tsx b/web/src/components/EventLog.tsx index f0fdf0f..7b3953e 100644 --- a/web/src/components/EventLog.tsx +++ b/web/src/components/EventLog.tsx @@ -28,6 +28,9 @@ const BATTLE_ICONS: Record = { release: '↩️', trumpet: '🎺', prevent: '🛡️', + mana: '🔮', + ailment: '👻', + bounce: '🌀', } // battleLogLines turns the battle events revealed up to `step` into readable diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 0f8695e..2906b79 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -74,6 +74,11 @@ export function ShopPhase({ view, you, send }: Props) { const myTurn = view.turn === view.youSeat && !you.ready const avocados = you.avocados ?? 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 canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy) // Nudge the player to pass once there's nothing left to buy. @@ -382,6 +387,28 @@ export function ShopPhase({ view, you, send }: Props) { 🎺 ×{trumpets} )} + {mana > 0 && ( +
+ 🔮 ×{mana} +
+ )} + {hasBigfoot && ( +
+ + {shopPeek && ( +
+ +
+ )} +
+ )} {/* Actions */} @@ -496,6 +523,28 @@ export function ShopPhase({ view, you, send }: Props) { )} + {/* Water of Youth sacrifice picker (Unicorn pack) */} + {view.pendingSacrifice?.playerId === you.id && ( +
+
+

Sacrifice a pet to draw a free tier {view.pendingSacrifice.tier} card

+
+ {(view.pendingSacrifice.options ?? []).map((id) => { + const c = deck.find((d) => d.id === id) + return c ? ( + send({ type: 'sacrificeChoose', card: id })} + /> + ) : null + })} +
+
+
+ )} + {cardFlyer && createPortal(
v {(p.avocados ?? 0) > 0 && ( 🥑 {p.avocados} )} + {(p.mana ?? 0) > 0 && ( + 🔮 {p.mana} + )}
))} diff --git a/web/src/petArt.ts b/web/src/petArt.ts index 97c9582..3a3def5 100644 --- a/web/src/petArt.ts +++ b/web/src/petArt.ts @@ -23,6 +23,27 @@ const PET_EMOJI: Record = { Nyala: '🦌', 'Nurse Shark': '🦈', 'Giant Isopod': '🦞', 'Blue-Ringed Octopus': '🐙', Raccoon: '🦝', 'Fire Ant': '🐜', Macaque: '🐒', // Golden pack — Tier 6 '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 Bee: '🐝', Apple: '🍎', diff --git a/web/src/styles.css b/web/src/styles.css index c53343e..5b9541d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1392,6 +1392,51 @@ h3 { 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. */ .shop-coins { display: flex; diff --git a/web/src/types.ts b/web/src/types.ts index 4b87591..499066b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,7 +1,7 @@ // Mirrors of the Go view types (internal/game/view.go). 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 interface Card { @@ -16,6 +16,7 @@ export interface Card { food?: string perk?: boolean temporary?: boolean + ailment?: string // 'spooked' | 'exposed' (Unicorn pack; kind === 'ailment') } export interface PlayerView { @@ -33,6 +34,8 @@ export interface PlayerView { firstBuyFree?: boolean // Manta Ray: next buy is free (self only) buysThisRound?: number // Blue-Ringed Octopus counter (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[] } @@ -47,6 +50,16 @@ export interface PendingReveal { playerId: string source: string 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 { @@ -66,6 +79,9 @@ export interface BattleEvent { | 'release' | 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (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 target?: number card?: Card @@ -131,6 +147,7 @@ export interface GameView { players: PlayerView[] pending?: PendingTrade pendingReveal?: PendingReveal + pendingSacrifice?: PendingSacrifice battle?: BattleResult winnerSeat: number log?: LogEntry[] @@ -148,6 +165,8 @@ export type ClientMessage = | { type: 'trade'; cards: string[] } | { type: 'tradeChoose'; pick: number } | { type: 'revealChoose'; card: string } + | { type: 'sacrificeChoose'; card: string } + | { type: 'peek' } | { type: 'pass' } | { type: 'arrange'; order: string[] } | { type: 'ready' }