diff --git a/improvements.txt b/improvements.txt new file mode 100644 index 0000000..578d36b --- /dev/null +++ b/improvements.txt @@ -0,0 +1,8 @@ +- When you're out of coins, highlight the pass button so you know to click it +- When it becomes your turn in the shop phase, do some big animation of "your turn" across the screen or something, with a sound. It's easy to not realize it's your turn right now. +- I found one occurrence of the rock animation not playing for the opponent. It was deterministic for this battle (if I rewound and replayed, it still skipped) but it worked in other battles. +- Under the hover state of the card, have a explainer box for the trigger +- In the shop, the coints you have remaining should be big golden discs above the buy row, instead of a little icon in the toolbar. +- The squirrel card has two colums to list it's two abilities, but we should just show them stacked vertically instead. +- The crocodile didn't seem to work. I played my last pet, and my opponent had a crocodile set aside, but it didn't roll the rocks. +- If you have lots of apples, they can go off-screen when they stack. Maybe make the play area larger? diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 7caea96..a52c750 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -35,11 +35,13 @@ import ( // Action is one move the bot wants to make, mirroring the client protocol. type Action struct { - Type string // "buy" | "sell" | "trade" | "tradeChoose" | "pass" | "arrange" | "ready" - Row int // buy - Cards []string // sell / trade - Pick int // tradeChoose - Order []string // arrange + Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | battleChoose + Row int // buy / buyAvocado + Cards []string // sell / trade + Pick int // tradeChoose + Order []string // arrange + CardID string // revealChoose (Cockatoo): the pet to reveal + Value int // battleChoose (Nurse Shark): Trumpets to spend } // Bot is a computer player at a fixed difficulty level. @@ -61,6 +63,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action { me := &v.Players[v.YouSeat] switch v.Phase { case game.PhaseShop: + if v.PendingReveal != nil { + if v.PendingReveal.PlayerID == me.ID { + return b.decideReveal(v) + } + return nil + } if v.Pending != nil { if v.Pending.PlayerID == me.ID { return b.decideTradeChoose(v, mem) @@ -78,6 +86,13 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action { return b.decideArrange(v, mem) } case game.PhaseBattle: + if v.PendingBattle != nil { + if v.PendingBattle.Seat == v.YouSeat { + // Spend as many Trumpets as allowed — more rocks is better. + return &Action{Type: "battleChoose", Value: v.PendingBattle.Max} + } + return nil + } if !me.Ready { return &Action{Type: "ready"} } @@ -85,6 +100,21 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action { return nil } +// decideReveal picks the highest-power eligible pet for Cockatoo's reveal, for +// the most apples. +func (b *Bot) decideReveal(v *game.View) *Action { + me := &v.Players[v.YouSeat] + best, bestPow := "", -1 + for _, id := range v.PendingReveal.Options { + for _, c := range me.Deck { + if c.ID == id && c.Power > bestPow { + best, bestPow = id, c.Power + } + } + } + return &Action{Type: "revealChoose", CardID: best} +} + // 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 { @@ -94,11 +124,21 @@ func Pending(v *game.View) bool { me := &v.Players[v.YouSeat] switch v.Phase { case game.PhaseShop: + if v.PendingReveal != nil { + return v.PendingReveal.PlayerID == me.ID + } if v.Pending != nil { return v.Pending.PlayerID == me.ID } return v.Turn == v.YouSeat && !me.Ready - case game.PhaseArrange, game.PhaseBattle: + case game.PhaseArrange: + return !me.Ready + case game.PhaseBattle: + // A pending mid-battle decision is owed only by the deciding seat; + // otherwise everyone owes the battle acknowledgement. + if v.PendingBattle != nil { + return v.PendingBattle.Seat == v.YouSeat + } return !me.Ready } return false diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 9b6dbe5..6fefe7e 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -11,7 +11,26 @@ import ( // It fails the test if a bot ever produces an illegal action or the game // stops making progress. func playBotGame(t *testing.T, levelA, levelB float64) *game.Game { + return playBotGamePack(t, game.DefaultPack, levelA, levelB) +} + +// forcePlayable temporarily marks a (possibly gated) pack Playable so tests can +// start a game on it, returning a restore func. +func forcePlayable(id string) func() { + for i := range game.Packs { + if game.Packs[i].ID == id { + prev := game.Packs[i].Playable + game.Packs[i].Playable = true + idx := i + return func() { game.Packs[idx].Playable = prev } + } + } + return func() {} +} + +func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game { t.Helper() + defer forcePlayable(pack)() g := game.New() pa, err := g.AddBot("Bot A", levelA) if err != nil { @@ -21,6 +40,9 @@ func playBotGame(t *testing.T, levelA, levelB float64) *game.Game { if err != nil { t.Fatalf("AddBot B: %v", err) } + if err := g.SetPack(pack); err != nil { + t.Fatalf("SetPack %s: %v", pack, err) + } if err := g.StartGame(); err != nil { t.Fatalf("StartGame: %v", err) } @@ -68,12 +90,18 @@ func applyAction(g *game.Game, playerID string, a *Action) error { switch a.Type { case "buy": return g.Buy(playerID, a.Row) + case "buyAvocado": + return g.BuyAvocado(playerID, a.Row) case "sell": return g.Sell(playerID, a.Cards) case "trade": return g.TradeStart(playerID, a.Cards) case "tradeChoose": return g.TradeChoose(playerID, a.Pick) + case "revealChoose": + return g.RevealChoose(playerID, a.CardID) + case "battleChoose": + return g.BattleChoose(playerID, a.Value) case "pass": return g.Pass(playerID) case "arrange": @@ -98,6 +126,19 @@ func TestBotsFinishGames(t *testing.T) { } } +// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3 +// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail +// battle mechanics via SimulateBattle rollouts and the new shop effects, and +// fails on any illegal or missing bot action. +func TestBotsFinishGoldenGame(t *testing.T) { + for range 5 { + g := playBotGamePack(t, "golden", 1, 0.6) + if g.Round != game.MaxRounds { + t.Errorf("golden 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/arrange.go b/internal/ai/arrange.go index 5d0b031..ff78d64 100644 --- a/internal/ai/arrange.go +++ b/internal/ai/arrange.go @@ -115,7 +115,7 @@ func placeSpread(pets []game.Card, foodIdx int, _ game.Card) int { func placeSynergy(pets []game.Card, foodIdx int, food game.Card) int { for i, p := range pets { switch p.Name { - case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion": + case "Rooster", "Dodo", "Leopard", "Peacock", "Scorpion", "Bulldog", "Macaque": return i } } diff --git a/internal/ai/eval.go b/internal/ai/eval.go index 0418ee4..079114f 100644 --- a/internal/ai/eval.go +++ b/internal/ai/eval.go @@ -113,6 +113,9 @@ func leadScore(c game.Card) float64 { s += 0.8 case game.TriggerHurt: s += 0.5 + case game.TriggerAfterAttack: + // Wants to survive its clashes to keep triggering (Bulldog). + s += 0.6 } } return s diff --git a/internal/ai/memory.go b/internal/ai/memory.go index eb95e5e..699bd6d 100644 --- a/internal/ai/memory.go +++ b/internal/ai/memory.go @@ -94,9 +94,15 @@ func Observe(v *game.View, m *Memory) { switch { case e.Kind == game.LogBuy: if c, ok := cardByID(m.PrevShopRow, e.Source); ok { - m.Opp.Known = append(m.Opp.Known, c) - } else if c, ok := templateByName(e.CardName); ok { - m.Opp.Known = append(m.Opp.Known, c) + // An Avocado buy is set aside, not kept in the deck (Golden + // pack): don't add it to the deck model. + if c.Food != game.FoodAvocado { + m.Opp.Known = append(m.Opp.Known, c) + } + } else if c, ok := templateByName(v.Pack, e.CardName); ok { + if c.Food != game.FoodAvocado { + m.Opp.Known = append(m.Opp.Known, c) + } } case e.Kind == game.LogSell: m.removeOppCard(e.Source, e.CardName) @@ -179,15 +185,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) { return game.Card{}, false } -// templateByName mints a reference copy of a named card from the printed -// tier contents. The suit is whatever the first printed copy has — callers -// only rely on stats and effects. -func templateByName(name string) (game.Card, bool) { +// templateByName mints a reference copy of a named card from the pack's +// printed tier contents. The suit is whatever the first printed copy has — +// callers only rely on stats and effects. +func templateByName(pack, name string) (game.Card, bool) { if name == "" { return game.Card{}, false } for tier := 1; tier <= game.MaxRounds; tier++ { - for _, c := range game.TierContents(tier) { + for _, c := range game.TierContentsForPack(pack, tier) { if c.Name == name { return c, true } diff --git a/internal/ai/predict.go b/internal/ai/predict.go index 411575c..2c84b5e 100644 --- a/internal/ai/predict.go +++ b/internal/ai/predict.go @@ -60,7 +60,7 @@ func (cx *ctx) unseenPool(tier int) []game.Card { note(c) } var pool []game.Card - for _, c := range game.TierContents(tier) { + for _, c := range game.TierContentsForPack(cx.v.Pack, tier) { if seen[c.Name] > 0 { seen[c.Name]-- continue @@ -78,7 +78,7 @@ func (cx *ctx) sampleOppDeck() []game.Card { deck := append([]game.Card(nil), cx.m.Opp.Known...) for _, h := range cx.m.Opp.Hidden { var c game.Card - if t, ok := templateByName(h.Name); ok { + if t, ok := templateByName(cx.v.Pack, h.Name); ok { c = t } else if pool := cx.unseenPool(h.Tier); len(pool) > 0 { c = pool[rand.IntN(len(pool))] diff --git a/internal/ai/shop.go b/internal/ai/shop.go index 4cc566f..75a0106 100644 --- a/internal/ai/shop.go +++ b/internal/ai/shop.go @@ -18,7 +18,26 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g } switch e.Action { case game.ActionGainApple: - for range max(e.Count, 1) { + n := max(e.Count, 1) + switch e.Per { + case game.PerShopFaintPets: + n *= countShopFaintPets(cx.v.ShopRow) + case game.PerBuysThisRound: + // This buy will bump the counter, so count it (Blue-Ringed Octopus). + n *= cx.me.BuysThisRound + 1 + } + for range n { + deck = append(deck, cx.simApple()) + } + case game.ActionRevealForApples: + // Cockatoo: the bot would reveal its highest-power other pet. + best := 0 + for _, d := range deck { + if d.IsPet() && d.ID != c.ID && d.Power > best { + best = d.Power + } + } + for range best { deck = append(deck, cx.simApple()) } case game.ActionDoubleApples: @@ -31,11 +50,54 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g for range apples { deck = append(deck, cx.simApple()) } + case game.ActionApplesInPlay: + // Golden pack (Hercules Beetle, sold): in-play apples aren't in the + // deck, but they buff the front pet, so approximate them as deck + // apples for scoring. MinRound is already gated above. + for range max(e.Count, 1) { + deck = append(deck, cx.simApple()) + } + case game.ActionBuyTopFree: + // Golden pack (Stoat): grabs an unknown top-of-deck card; approximate + // with a sampled card of the current tier. + pool := cx.unseenPool(cx.v.Round) + if len(pool) == 0 { + pool = game.TierContentsForPack(cx.v.Pack, cx.v.Round) + } + if len(pool) > 0 { + rc := pool[rand.IntN(len(pool))] + rc.ID = cx.nextSimID() + deck = append(deck, rc) + } + case game.ActionSetAside: + // Golden pack (Avocado): the just-bought token is set aside, not + // kept in the deck being scored. + if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 { + deck = slices.Delete(deck, i, i+1) + } } } return deck } +// countShopFaintPets counts pets in the shop row with a Faint effect (mirrors +// the engine's Opossum payout). +func countShopFaintPets(row []game.Card) int { + n := 0 + for _, c := range row { + if c.ID == "" || !c.IsPet() { + continue + } + for _, e := range c.Effects { + if e.Trigger == game.TriggerFaint { + n++ + break + } + } + } + return n +} + func (cx *ctx) simApple() game.Card { return game.Card{ ID: cx.nextSimID(), @@ -133,6 +195,25 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { } } + // Golden pack: buying by discarding an Avocado yields the same deck as a + // coin buy, so it's only preferred when coins are scarce — a small negative + // bias keeps the token in reserve otherwise. + if cx.me.Avocados > 0 { + for i, c := range v.ShopRow { + if c.ID == "" { + continue + } + nd := append(slices.Clone(deck), c) + nd = cx.applyTemplateShopEffects(nd, c, game.TriggerBuy) + nd = cx.previewSellDown(nd) + cands = append(cands, candidate{ + act: &Action{Type: "buyAvocado", Row: i}, + decks: [][]game.Card{nd}, + bias: -0.05, + }) + } + } + // Sell candidates: the worst 1, 2, or 3 keepers. Selling is free and // takes any number of cards, so bulk-dumping junk before a battle is one // action. Temporary cards are excluded — selling an apple for an apple @@ -201,7 +282,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action { } pool := cx.unseenPool(v.Round + 1) if len(pool) == 0 { - pool = game.TierContents(v.Round + 1) + pool = game.TierContentsForPack(v.Pack, v.Round+1) } var decks [][]game.Card for range 3 { diff --git a/internal/game/battle.go b/internal/game/battle.go index c112869..e322c59 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -1,6 +1,9 @@ package game -import "fmt" +import ( + "fmt" + "slices" +) // BattleUnit is a pet in play with its attached foods applied. Power // (attack) is unaffected by damage; a unit dies when Damage >= Power. @@ -13,6 +16,11 @@ type BattleUnit struct { // its source so the log can name it (Gorilla's innate block, or a Melon // perk) and the client can drop the spent card. Shields []shieldCharge + // afterAttackUsed gates once-per-battle After-Attack effects (Bulldog). + afterAttackUsed bool + // 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 } // shieldCharge is one full-hit block a pet carries. source is the granting @@ -32,6 +40,15 @@ type shieldBlock struct { release *Card } +// preventInfo describes a partial damage prevention a hit just consumed (Cone +// Snail): amount is the damage shaved off, release is the set-aside card to +// remove from the board. The caller narrates it after its own hit event so the +// replay order stays correct. +type preventInfo struct { + amount int + release *Card +} + func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() } @@ -160,6 +177,13 @@ type lastPetVolley struct { src Card // the fainted pet, for the set-aside display } +// feedAside is Giant Isopod's set-aside: each time the owner plays a pet, spend +// one Trumpet to feed that pet `apples` apples. +type feedAside struct { + apples int + src Card // the fainted pet, for the set-aside display +} + // battleSide is one seat's live state during the simulation. type battleSide struct { stack []Card // remaining face-down cards, top first @@ -175,6 +199,17 @@ type battleSide struct { recurringRocks []setAsideRocks // Snake: on every own pet play lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet shieldCards []Card // Turtle set-aside cards, parallel to shields + + // --- Golden pack --- + trumpets int // ephemeral Trumpet pool (earned/spent in battle) + faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat) + grSummoned bool // Golden Retriever already summoned this battle + hitPrevent []int // Cone Snail: pending one-shot partial damage preventions + preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent + beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played + 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 } // hasPetInStack reports whether any pet remains face-down in the stack. @@ -187,6 +222,17 @@ func (s *battleSide) hasPetInStack() bool { return false } +// canField reports whether the side has, or can still produce, a pet to fight: +// one in play, a pet left in the stack, or a Golden Retriever waiting to be +// summoned. Used to decide the battle is over only after play effects (and any +// parting shots) have resolved. +func (s *battleSide) canField() bool { + if s.unit != nil || s.hasPetInStack() { + return true + } + return len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned +} + // queuedPlay is a play-time effect waiting to resolve after reveals. type queuedPlay struct { seat int @@ -198,8 +244,9 @@ type queuedPlay struct { } // effectCount resolves an effect's final count: base × Per statistic, -// limited by Cap. -func effectCount(e Effect, s *battleSide, u *BattleUnit) int { +// limited by Cap. enemy is the opposing side (for enemy-relative multipliers); +// it may be nil when the effect has no such Per. +func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int { n := e.count() switch e.Per { case PerFaintedBees: @@ -210,6 +257,14 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int { n *= appleCount(u.Foods) case PerPower: n *= u.Power() + case PerUniqueFaintedHats: + n *= len(s.faintedHats) + case PerEnemyFaintedPets: + if enemy != nil { + n *= enemy.petsFainted + } else { + n = 0 + } } if e.Cap > 0 && n > e.Cap { n = e.Cap @@ -231,20 +286,72 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int { // entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash // attack manages to hurt. A clash that changes nothing ends the battle as a // stalemate. +// +// resolveBattle is the orchestrator: it re-runs the (deterministic) simulation +// from the recorded dice/decision tapes, publishing either a completed result +// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark). func (g *Game) resolveBattle() { + g.NextCardID = g.BattleCardBase + g.battleRollCursor = 0 + g.battleDecisionCursor = 0 + res, pending := g.runBattle() + g.Battle = res + if pending != nil { + g.PendingBattle = pending + return + } + g.PendingBattle = nil + g.finalizeBattle(res) +} + +// finalizeBattle applies the persistent effects of a completed battle: trophies, +// the priority token hand-off, the result log line, and clearing the per-round +// apples-in-play bank. Deferred here (not inside runBattle) because runBattle +// may re-run several times before the battle actually completes. +func (g *Game) finalizeBattle(res *BattleResult) { + n := len(g.Players) + winner := res.WinnerSeat + if winner >= 0 { + g.Players[winner].Trophies += res.Trophies + // Priority token: the winner hands it to the other player; a loser who + // held it keeps it; a draw leaves it put. (Two-player rule.) + if winner == g.PrioritySeat { + g.PrioritySeat = (winner + 1) % n + } + } + if winner < 0 { + g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult, + Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)}) + } else { + g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult, + Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)}) + } + for _, p := range g.Players { + p.PendingApplesInPlay = 0 + p.PendingTrumpets = 0 + } +} + +// runBattle plays the simulation to completion or until it needs a mid-battle +// decision, returning the (partial) result and a non-nil pending in the latter +// case. It mutates no persistent player state — that is finalizeBattle's job. +func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) { n := len(g.Players) res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)} + var suspended *PendingBattleDecision sides := make([]*battleSide, n) emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } // pname is the owning player's display name for a seat, for log text. pname := func(seat int) string { return g.Players[seat].Name } for _, p := range g.Players { - s := &battleSide{stack: append([]Card(nil), p.Deck...)} + s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}} sides[p.Seat] = s res.StackSizes[p.Seat] = len(p.Deck) res.Lineups[p.Seat] = append([]Card(nil), p.Deck...) } + // enemyOf returns the opposing side (two-player; generalizes later). + enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] } // seatOrder resolves the priority-token holder first, then everyone else. // Reveals, queued play effects, and cross-side triggers all follow it, so // when two pets would act simultaneously (e.g. both throwing rocks) the @@ -257,6 +364,13 @@ func (g *Game) resolveBattle() { seatOrder = append(seatOrder, seat) } } + // startApple seeds one in-play apple onto a seat's first pet. + startApple := func(seat int) { + apple := g.newApple() + sides[seat].pending = append(sides[seat].pending, apple) + emit(BattleEvent{Type: "prep", Seat: seat, Card: &apple, + Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(seat))}) + } // Battle-prep effects that start apples in play (Monkey): they attach // to the owner's first pet. for _, p := range g.Players { @@ -264,14 +378,23 @@ func (g *Game) resolveBattle() { for _, e := range c.Effects { if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay { for range e.count() { - apple := g.newApple() - sides[p.Seat].pending = append(sides[p.Seat].pending, apple) - emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple, - Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))}) + startApple(p.Seat) } } } } + // Golden pack: apples-in-play banked by a sold Hercules Beetle this + // round (read-only here; finalizeBattle clears it once the battle ends, + // so re-runs bank the same amount). + for range p.PendingApplesInPlay { + startApple(p.Seat) + } + // Bird of Paradise: start the battle with Trumpets in the pool. + if p.PendingTrumpets > 0 { + sides[p.Seat].trumpets += p.PendingTrumpets + emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets, + Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))}) + } } summon := func(seat int, c Card, cause string) { @@ -280,12 +403,43 @@ func (g *Game) resolveBattle() { emit(BattleEvent{Type: "summon", Seat: seat, Card: &c, Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)}) } + // summonBottom puts a card on the BOTTOM of a seat's stack (Bear). + summonBottom := func(seat int, c Card, cause string) { + s := sides[seat] + s.stack = append(s.stack, c) + emit(BattleEvent{Type: "summon", Seat: seat, Card: &c, + Text: fmt.Sprintf("%s puts %s %s on the bottom of %s's deck.", cause, article(c.Name), c.Name, pname(seat))}) + } mintFor := func(kind string) Card { if kind == "bee" { return g.newBee() } return g.newApple() } + // gainTrumpets adds trumpets to a side and narrates it. + gainTrumpets := func(seat, n int, cause string) { + if n <= 0 { + return + } + sides[seat].trumpets += n + 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 + } + if sides[seat].trumpets < e.CostTrumpet { + 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))}) + return true + } // allowed gates battle-time effects on their conditions. allowed := func(e Effect, u *BattleUnit) bool { @@ -306,10 +460,10 @@ func (g *Game) resolveBattle() { // when a shield absorbed the hit, a shieldBlock describing it (nil // otherwise). A set-aside Turtle shield is spent before the pet's own // shields (Melon/Gorilla) so the borrowed card clears the board first. - hitUnit := func(seat, amount int) (dealt int, block *shieldBlock) { + hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) { u := sides[seat].unit if u == nil || amount <= 0 { - return 0, nil + return 0, nil, nil } if sides[seat].shields > 0 { sides[seat].shields-- @@ -321,16 +475,36 @@ func (g *Game) resolveBattle() { card = &c source = c.Name } - return 0, &shieldBlock{source: source, release: card} + return 0, &shieldBlock{source: source, release: card}, nil } if n := len(u.Shields); n > 0 { sc := u.Shields[n-1] u.Shields = u.Shields[:n-1] - return 0, &shieldBlock{source: sc.source, release: sc.card} + return 0, &shieldBlock{source: sc.source, release: sc.card}, nil } - dealt = max(0, amount-u.prevention()) + reduce := u.prevention() + // One partial-prevention charge per hit (whether or not it fully absorbs + // the blow): the pet's own first (Potato), else a side-level set-aside + // (Cone Snail). + if len(u.hitPrevent) > 0 { + amt := u.hitPrevent[0] + u.hitPrevent = u.hitPrevent[1:] + reduce += amt + prevent = &preventInfo{amount: amt} + } else if len(sides[seat].hitPrevent) > 0 { + amt := sides[seat].hitPrevent[0] + sides[seat].hitPrevent = sides[seat].hitPrevent[1:] + reduce += amt + prevent = &preventInfo{amount: amt} + if len(sides[seat].preventCards) > 0 { + c := sides[seat].preventCards[0] + sides[seat].preventCards = sides[seat].preventCards[1:] + prevent.release = &c + } + } + dealt = max(0, amount-reduce) u.Damage += dealt - return dealt, nil + return dealt, nil, prevent } // emitShield narrates a blocked hit — naming the source rather than a bare @@ -347,6 +521,21 @@ func (g *Game) resolveBattle() { } } + // emitPrevent narrates a Cone Snail partial prevention after the hit that + // consumed it, and drops the spent set-aside card. + emitPrevent := func(seat int, petName string, prev *preventInfo) { + u := sides[seat].unit + after := 0 + if u != nil { + after = u.Damage + } + emit(BattleEvent{Type: "prevent", Seat: seat, Count: prev.amount, DamageAfter: after, + Text: fmt.Sprintf("A set-aside Cone Snail shields %s, preventing %d damage.", petName, prev.amount)}) + if prev.release != nil { + emit(BattleEvent{Type: "release", Seat: seat, Card: prev.release}) + } + } + // faint fires the unit's faint effects (its own and its perk's) in // effect order, updates faint counters, and notifies enemy pets // (Hippo's heal). @@ -357,6 +546,11 @@ func (g *Game) resolveBattle() { if isBee(u.Card) { s.beesFainted++ } + // 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 != "" { + s.faintedHats[u.Card.Suit] = true + } // setAside marks the fainted pet as kept beside the arena with a // pending effect, so the client can show its card until it resolves. setAside := func() { @@ -364,19 +558,47 @@ func (g *Game) resolveBattle() { emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c, Text: fmt.Sprintf("%s is set aside.", c.Name)}) } + cause := fmt.Sprintf("%s's faint effect", u.Card.Name) for _, e := range u.effects() { if e.Trigger != TriggerFaint || !allowed(e, u) { continue } + if !spend(seat, e, u.Card.Name) { + continue + } switch e.Action { case ActionSummonTop: target := seat if e.Target == "enemy" { target = (seat + 1) % n } - for range effectCount(e, s, u) { - summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name)) + for range effectCount(e, s, u, enemyOf(seat)) { + summon(target, mintFor(e.Card), cause) } + case ActionSummonBottom: + for range effectCount(e, s, u, enemyOf(seat)) { + if e.Target == "all" { + for other := range sides { + summonBottom(other, mintFor(e.Card), cause) + } + } else { + summonBottom(seat, mintFor(e.Card), cause) + } + } + case ActionGainTrumpet: + gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause) + case ActionDrainTrumpet: + es := enemyOf(seat) + lost := min(e.count(), es.trumpets) + if lost > 0 { + es.trumpets -= lost + emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost, + Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))}) + } + case ActionPreventNextHit: + s.hitPrevent = append(s.hitPrevent, e.count()) + s.preventCards = append(s.preventCards, u.Card) + setAside() case ActionRecycleApples: recycled := 0 for _, f := range u.Foods { @@ -385,6 +607,28 @@ func (g *Game) resolveBattle() { recycled++ } } + case ActionRecyclePerkApples: + // Macaque: recycle up to Count apples, then the active perk on + // top (so the perk reveals first and re-attaches to the next pet). + recycled := 0 + for _, f := range u.Foods { + if f.Food == FoodApple && recycled < e.count() { + summon(seat, f, cause) + recycled++ + } + } + if perk := u.activePerk(); perk != nil { + summon(seat, *perk, cause) + } + case ActionBeeRocks: + s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card}) + setAside() + case ActionFeedOnPlay: + s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card}) + setAside() + case ActionGuardRetriever: + s.retrieverGuards = append(s.retrieverGuards, e.count()) + setAside() case ActionDelayedRocks: s.oneShotRocks = append(s.oneShotRocks, setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card}) @@ -417,7 +661,7 @@ func (g *Game) resolveBattle() { if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) { continue } - healed := min(effectCount(e, os, os.unit), os.unit.Damage) + healed := min(effectCount(e, os, os.unit, sides[seat]), os.unit.Damage) if healed > 0 { os.unit.Damage -= healed emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage, @@ -436,18 +680,23 @@ func (g *Game) resolveBattle() { if e.Trigger != TriggerHurt || !allowed(e, u) { continue } + if !spend(seat, e, u.Card.Name) { + continue + } switch e.Action { case ActionEatApple: - for range effectCount(e, sides[seat], u) { + for range effectCount(e, sides[seat], u, enemyOf(seat)) { u.Foods = append(u.Foods, g.newApple()) u.Bonus++ } emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)}) case ActionSummonTop: - for range effectCount(e, sides[seat], u) { + for range effectCount(e, sides[seat], u, enemyOf(seat)) { summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name)) } + case ActionGainTrumpet: + gainTrumpets(seat, effectCount(e, sides[seat], u, enemyOf(seat)), fmt.Sprintf("%s's hurt effect", u.Card.Name)) case ActionShieldSelf: // Gorilla's own hurt-triggered block: innate, no card to drop. for range e.count() { @@ -457,6 +706,32 @@ func (g *Game) resolveBattle() { } } + // 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. + afterAttack := func(seat int, u *BattleUnit) { + if !u.Alive() || u.afterAttackUsed { + return + } + for _, e := range u.effects() { + if e.Trigger != TriggerAfterAttack || !allowed(e, u) { + continue + } + if !spend(seat, e, u.Card.Name) { + continue + } + switch e.Action { + case ActionEatApple: + for range effectCount(e, sides[seat], u, enemyOf(seat)) { + u.Foods = append(u.Foods, g.newApple()) + u.Bonus++ + } + emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus, + Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)}) + } + 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) { @@ -470,7 +745,7 @@ func (g *Game) resolveBattle() { faces[i] = g.rollRockDie() roll += faces[i] } - dealt, block := hitUnit(target, roll) + dealt, block, prev := hitUnit(target, roll) died := !tu.Alive() // A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a // different pet has been played, so credit `source` explicitly; only @@ -506,6 +781,9 @@ func (g *Game) resolveBattle() { if block != nil { emitShield(target, tu.Card.Name, block) } + if prev != nil { + emitPrevent(target, tu.Card.Name, prev) + } if died { faint(target, tu) sides[target].unit = nil @@ -571,6 +849,7 @@ func (g *Game) resolveBattle() { emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt}) s.pending = nil s.unit = u + s.petsPlayed++ newlyPlayed[seat] = true // Set-aside payouts fire before the new pet's own play // effects. @@ -585,6 +864,31 @@ func (g *Game) resolveBattle() { plays = append(plays, queuedPlay{seat: seat, effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src}) } + // Poison Dart Frog: rocks whenever a Bee is played. + if isBee(c) { + for _, r := range s.beePlayRocks { + src := r.src + plays = append(plays, queuedPlay{seat: seat, + effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src}) + } + } + // Giant Isopod: each set-aside spends one Trumpet (mandatory when + // affordable) to feed the just-played pet its apples. + for i := range s.feedOnPlay { + if s.trumpets <= 0 { + break + } + s.trumpets-- + src := s.feedOnPlay[i].src + emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -1, + Text: fmt.Sprintf("%s spends 1 Trumpet.", src.Name)}) + for range s.feedOnPlay[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.feedOnPlay[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). @@ -593,8 +897,11 @@ func (g *Game) resolveBattle() { return } // Shields apply the instant the pet enters play, ahead of - // any queued rocks (Melon). + // any queued rocks (Melon; Wildebeest, which pays Trumpets). if e.Action == ActionShieldSelf { + if !spend(seat, e, u.Card.Name) { + return + } for range e.count() { ch := shieldCharge{} if perk != nil { @@ -605,6 +912,13 @@ func (g *Game) resolveBattle() { } return } + // Potato's partial preventions likewise arm on entry. + if e.Action == ActionPreventSelf { + for range max(e.Cap, 1) { + u.hitPrevent = append(u.hitPrevent, e.count()) + } + return + } plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) } for _, e := range u.Card.Effects { @@ -616,6 +930,24 @@ func (g *Game) resolveBattle() { } } } + // Golden pack: a side that has run out of cards but still holds + // Trumpets fields a one-time Golden Retriever, its Power equal to + // those Trumpets. This happens before the "anyone out?" check so it + // can still clash. + if s.unit == nil && len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned { + gr := g.newGoldenRetriever(s.trumpets) + grUnit := &BattleUnit{Card: gr} + // German Shepherd: each set-aside guards the Golden Retriever's + // first hits (partial preventions). + grUnit.hitPrevent = append(grUnit.hitPrevent, s.retrieverGuards...) + s.unit = grUnit + s.grSummoned = true + s.petsPlayed++ + newlyPlayed[seat] = true + emit(BattleEvent{Type: "reveal", Seat: seat, Card: &gr, Count: s.trumpets, Bonus: 0, + Text: fmt.Sprintf("%s is out of cards — a Golden Retriever charges in with %d Trumpet%s (Power %d).", + pname(seat), s.trumpets, plural(s.trumpets), s.trumpets)}) + } } // Cross-side play triggers: Rhino rocks anyone who just played; // Crocodile volleys when the enemy plays their last pet. @@ -655,20 +987,9 @@ func (g *Game) resolveBattle() { } } - // Battle over? A side that couldn't field a pet is out; no further - // effects resolve. - anyOut := false - for _, s := range sides { - if s.unit == nil { - anyOut = true - } - } - if anyOut { - break - } - - // Resolve play effects. Any of these can faint a pet before the - // clash. + // Resolve play effects. Any of these can faint a pet before the clash. + // This runs before the "battle over" check so a parting shot fires even + // when its own side is already out (Crocodile's last-pet volley). anyDeath := false for _, q := range plays { // Effects sourced from a specific unit fizzle if it's gone. @@ -678,26 +999,97 @@ func (g *Game) resolveBattle() { if q.unit != nil && !allowed(q.effect, q.unit) { continue } + costName := pname(q.seat) + if q.unit != nil { + costName = q.unit.Card.Name + } + if !spend(q.seat, q.effect, costName) { + continue + } switch q.effect.Action { case ActionThrowRock: dice := q.effect.count() if q.unit != nil { - dice = effectCount(q.effect, sides[q.seat], q.unit) + dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) } - if q.everyone { + switch { + case q.everyone: for seat := range sides { if throwRocks(q.seat, seat, dice, q.source) { anyDeath = true } } - } else if t := nextTarget(q.seat); t >= 0 { - if throwRocks(q.seat, t, dice, q.source) { + case q.effect.Target == "self": + // Manatee pelts its own pet. + if throwRocks(q.seat, q.seat, dice, q.source) { anyDeath = true } + default: + if t := nextTarget(q.seat); t >= 0 { + if throwRocks(q.seat, t, dice, q.source) { + anyDeath = true + } + } } if q.release != nil { emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release}) } + case ActionGainTrumpet: + gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName) + case ActionDoubleTrumpets: + gain := sides[q.seat].trumpets + if q.effect.Cap > 0 && gain > q.effect.Cap { + gain = q.effect.Cap + } + gainTrumpets(q.seat, gain, costName) + case ActionStealPerk: + t := nextTarget(q.seat) + if t < 0 { + continue + } + tu := sides[t].unit + perk := tu.activePerk() + if perk == nil { + continue + } + stolen := *perk + // Drop that perk food from the enemy pet (perks add no power, so + // no bonus change) and put it on top of the thief's deck. + for i := range tu.Foods { + if tu.Foods[i].ID == stolen.ID { + tu.Foods = append(tu.Foods[:i], tu.Foods[i+1:]...) + break + } + } + summon(q.seat, stolen, fmt.Sprintf("%s's ability", q.unit.Card.Name)) + emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, + Text: fmt.Sprintf("%s snatches %s's %s.", q.unit.Card.Name, tu.Card.Name, stolen.Name)}) + case ActionStripApples: + t := nextTarget(q.seat) + if t < 0 { + continue + } + tu := sides[t].unit + apples := appleCount(tu.Foods) + if apples == 0 { + continue + } + kept := tu.Foods[:0] + for _, f := range tu.Foods { + if f.Food != FoodApple { + kept = append(kept, f) + } + } + tu.Foods = kept + tu.Bonus -= apples + died := !tu.Alive() + emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died, + Text: fmt.Sprintf("%s discards %s's apples.", q.unit.Card.Name, tu.Card.Name)}) + if died { + faint(t, tu) + sides[t].unit = nil + anyDeath = true + } case ActionStripFoods: t := nextTarget(q.seat) if t < 0 { @@ -758,11 +1150,11 @@ func (g *Game) resolveBattle() { Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))}) } case ActionSummonTop: - for range effectCount(q.effect, sides[q.seat], q.unit) { + for range effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) { summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name)) } case ActionEatApple: - count := effectCount(q.effect, sides[q.seat], q.unit) + count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) if count > 0 { for range count { q.unit.Foods = append(q.unit.Foods, g.newApple()) @@ -771,7 +1163,65 @@ func (g *Game) resolveBattle() { 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 ActionShuffleApples: + // Komodo: only if it's the side's first pet, shuffle apples into + // random deck positions (deterministic via the battle draw tape). + if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 { + continue + } + s := sides[q.seat] + for range q.effect.count() { + pos := g.battleDraw(len(s.stack) + 1) + apple := g.newApple() + s.stack = slices.Insert(s.stack, pos, apple) + emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple, + Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))}) + } + case ActionSpendRocks: + // Nurse Shark: the owner chooses how many Trumpets (0..available, + // capped at Count) to spend; each throws two rocks. This is the one + // mid-battle decision — it may suspend the whole simulation. + s := sides[q.seat] + maxSpend := min(q.effect.count(), s.trumpets) + choice, pending := g.decideBattle(PendingBattleDecision{ + Seat: q.seat, Kind: "nurseShark", PetName: q.unit.Card.Name, + Min: 0, Max: maxSpend, Trumpets: s.trumpets, + }) + if pending != nil { + suspended = pending + break + } + if choice > 0 { + s.trumpets -= choice + emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice, + Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))}) + if t := nextTarget(q.seat); t >= 0 { + if throwRocks(q.seat, t, 2*choice, nil) { + anyDeath = true + } + } + } } + if suspended != nil { + break // stop mid-plays; the battle re-runs once the choice is in + } + } + // A pending decision unwinds the whole simulation; the events emitted so + // far are a valid prefix the re-run reproduces exactly. + if suspended != nil { + return res, suspended + } + // Battle over? A side that can no longer field a pet is out (checked + // after play effects so parting shots land, using canField so a pet that + // will simply refill next reveal doesn't count as out). + anyOut := false + for _, s := range sides { + if !s.canField() { + anyOut = true + } + } + if anyOut { + break } if anyDeath { continue // refill before any clash @@ -781,8 +1231,8 @@ func (g *Game) resolveBattle() { // (the surrounding state is already per-seat). ua, ub := sides[0].unit, sides[1].unit powA, powB := ua.Power(), ub.Power() - dealtA, blockA := hitUnit(0, powB) - dealtB, blockB := hitUnit(1, powA) + dealtA, blockA, prevA := hitUnit(0, powB) + dealtB, blockB, prevB := hitUnit(1, powA) // Scorpion: a clash attack that hurts, KOs. if dealtA > 0 && ub.hasKnockout() { ua.Damage = max(ua.Damage, ua.Power()) @@ -828,7 +1278,14 @@ func (g *Game) resolveBattle() { if blockB != nil { emitShield(1, ub.Card.Name, blockB) } - if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil { + if prevA != nil { + emitPrevent(0, ua.Card.Name, prevA) + } + if prevB != nil { + emitPrevent(1, ub.Card.Name, prevB) + } + if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && + blockA == nil && blockB == nil && prevA == nil && prevB == nil { break // stalemate: nothing can ever change } dealt := []int{dealtA, dealtB} @@ -836,8 +1293,11 @@ func (g *Game) resolveBattle() { if !u.Alive() { faint(seat, u) sides[seat].unit = nil - } else if dealt[seat] > 0 { - hurt(seat, u) + } else { + if dealt[seat] > 0 { + hurt(seat, u) + } + afterAttack(seat, u) } } } @@ -860,27 +1320,6 @@ func (g *Game) resolveBattle() { if g.Round == MaxRounds { res.Trophies = 2 } - g.Players[winner].Trophies += res.Trophies - // Priority token: the winner hands it to the other player; a loser - // who held it keeps it; a draw leaves it put. (Two-player rule; the - // "other player" is unambiguous only at n == 2.) - if winner == g.PrioritySeat { - g.PrioritySeat = (winner + 1) % n - } - } - - g.Battle = res - g.Phase = PhaseBattle - // Tagged "result" so the client can hold it back until the replay finishes - // (the outcome is known now, but showing it early would spoil the battle). - if winner < 0 { - g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult, - Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)}) - } else { - g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult, - Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)}) - } - for _, p := range g.Players { - p.Ready = false } + return res, nil } diff --git a/internal/game/cards.go b/internal/game/cards.go index bf66d4d..016e8ce 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -29,6 +29,14 @@ const ( FoodPineapple = "pineapple" FoodChili = "chili" FoodMelon = "melon" + // FoodAvocado (Golden pack) is a persistent set-aside token: buying it puts + // it aside rather than in the deck, and it can later be discarded in place + // of spending a coin on a buy. It is neither a perk nor a battle food. + FoodAvocado = "avocado" + // FoodPotato / FoodDurian / FoodTomato (Golden pack) are perk foods. + FoodPotato = "potato" + FoodDurian = "durian" + FoodTomato = "tomato" ) // EffectTrigger is when an effect fires. @@ -51,6 +59,13 @@ const ( // TriggerPassive marks always-on effects (e.g. Garlic's damage // prevention); they're consulted contextually rather than fired. TriggerPassive EffectTrigger = "passive" + // TriggerAfterAttack fires on a pet right after it survives a clash it + // took part in (Golden pack: Bulldog). Effects here may be marked once + // per battle via the unit's after-attack flag. + TriggerAfterAttack EffectTrigger = "afterAttack" + // TriggerShopStart fires on a pet in the deck at the moment a shop round + // opens (Golden pack: Manta Ray). + TriggerShopStart EffectTrigger = "shopStart" ) // EffectAction is what the effect does. @@ -124,6 +139,77 @@ const ( // ActionPetAura (faint) sets the pet aside: the owner's pets have // +Count power for the rest of the battle (Mammoth). ActionPetAura EffectAction = "petAura" + + // --- Golden pack --- + // ActionGainTrumpet adds Count Trumpets to the acting side's battle pool + // (Groundhog, Black-Necked Stilt, Guinea Fowl, Osprey, Honduran White + // Bat). With Per set, Count is multiplied by a battle statistic. + ActionGainTrumpet EffectAction = "gainTrumpet" + // ActionDrainTrumpet removes up to Count Trumpets from the enemy side's + // pool (Flea). + ActionDrainTrumpet EffectAction = "drainTrumpet" + // ActionPreventNextHit (faint) sets the pet aside: the next time a friendly + // pet is hit, prevent Count damage from that hit (Cone Snail). Unlike + // ActionShieldNext (Turtle), it reduces rather than fully blocks. + ActionPreventNextHit EffectAction = "preventNextHit" + // ActionSummonBottom puts Count cards (Effect.Card) on the BOTTOM of a + // deck. With Target "all" it hits every player's deck (Bear). + ActionSummonBottom EffectAction = "summonBottom" + // ActionBuyTopFree (sell) takes the top card of the current tier's shop + // deck into the player's deck for free, firing its Buy effect (Stoat). + ActionBuyTopFree EffectAction = "buyTopFree" + // ActionSetAside (buy) diverts the just-bought card out of the deck into a + // persistent set-aside zone (Avocado). Shop-time. + ActionSetAside EffectAction = "setAside" + + // --- Golden pack, tiers 4-5 --- + // ActionDoubleTrumpets (play) grants the side extra Trumpets equal to what + // it holds, up to Cap (Vaquita). + ActionDoubleTrumpets EffectAction = "doubleTrumpets" + // ActionBeeRocks (faint) sets the pet aside: each time its owner plays a + // Bee, throw Count rocks at the enemy (Poison Dart Frog). + ActionBeeRocks EffectAction = "beeRocks" + // ActionFeedOnPlay (faint) sets the pet aside: each time its owner plays a + // pet, spend 1 Trumpet to feed that pet Count apples — mandatory when + // affordable (Giant Isopod). + ActionFeedOnPlay EffectAction = "feedOnPlay" + // ActionStealPerk (play) takes the enemy pet's active perk and puts it on + // top of this pet's owner's deck (Raccoon). + ActionStealPerk EffectAction = "stealPerk" + // ActionRecyclePerkApples (faint) puts this pet's active perk and up to + // Count of its apples on top of the owner's deck (Macaque). + ActionRecyclePerkApples EffectAction = "recyclePerkApples" + // ActionPreventSelf (play) gives the pet Cap one-shot charges that each + // prevent Count damage from a hit (Potato perk). + ActionPreventSelf EffectAction = "preventSelf" + // ActionStripApples (play) discards every apple attached to the enemy pet + // in play (Durian perk) — like ActionStripFoods but apples only. + ActionStripApples EffectAction = "stripApples" + // ActionSpendRocks (play) asks the owner how many Trumpets to spend (0..Count) + // and throws twice that many rocks at the enemy (Nurse Shark). This is the + // one battle-time player decision. + ActionSpendRocks EffectAction = "spendRocks" + // ActionFirstBuyFree (shop start) makes the player's first Buy this round + // cost no gold, if they hold Count or fewer pets (Manta Ray). + ActionFirstBuyFree EffectAction = "firstBuyFree" + // ActionRevealForApples (buy) asks the player to reveal another pet in hand; + // they gain apples equal to its power (Cockatoo). Shop-time decision. + ActionRevealForApples EffectAction = "revealForApples" + + // --- Golden pack, tier 6 --- + // ActionShuffleApples (play) shuffles Count apples into random positions of + // the owner's remaining deck (Komodo). Gated by ConditionFirstPet. + ActionShuffleApples EffectAction = "shuffleApples" + // ActionReactivateBuys (battle prep) re-fires the Buy ability of every pet + // in the owner's hand (Catfish). + ActionReactivateBuys EffectAction = "reactivateBuys" + // ActionStartTrumpets (buy) banks Count Trumpets to start the next battle + // with (Bird of Paradise). + ActionStartTrumpets EffectAction = "startTrumpets" + // ActionGuardRetriever (faint) sets the pet aside: the owner's Golden + // Retriever, when summoned, prevents Count damage on its first hit (German + // Shepherd). + ActionGuardRetriever EffectAction = "guardRetriever" ) // Per multipliers for dynamic effect counts. @@ -132,12 +218,25 @@ const ( PerFaintedPets = "faintedPets" // × friendly pets fainted this battle PerEatenApples = "eatenApples" // × apples this pet ate (its attached apples) PerPower = "power" // × this pet's current power + // PerUniqueFaintedHats × distinct suits ("hats") among friendly fainted + // pets this battle (Honduran White Bat). Battle-time. + PerUniqueFaintedHats = "uniqueFaintedHats" + // PerEnemyFaintedPets × pets the ENEMY side has fainted this battle (Royal + // Flycatcher). Battle-time. + PerEnemyFaintedPets = "enemyFaintedPets" + // PerShopFaintPets × pets currently in the shop row with a Faint effect + // (Opossum). Shop-time. + PerShopFaintPets = "shopFaintPets" + // PerBuysThisRound × Buy actions the player has taken this round, including + // the current one (Blue-Ringed Octopus). Shop-time. + PerBuysThisRound = "buysThisRound" ) // Effect conditions. const ( - ConditionTripled = "tripledThisRound" // player Tripled during this round's shop - ConditionHasPerk = "hasPerk" // this pet has a perk attached + 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) ) // Effect is one trigger→action pair printed on a card. @@ -155,6 +254,10 @@ type Effect struct { Condition string `json:"condition,omitempty"` // MinRound gates the effect to round >= MinRound (0 = always). MinRound int `json:"minRound,omitempty"` + // CostTrumpet, when > 0, is a Trumpet cost the acting side must pay for the + // 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"` } // count normalizes the zero value to 1. @@ -476,6 +579,272 @@ var foodTiers = [MaxRounds][]foodTemplate{ }, } +// goldenPetTiers defines the Golden pack's pets — all six tiers. Each pet ships +// as two copies (one per listed suit). +var goldenPetTiers = [MaxRounds][]petTemplate{ + { // Tier 1 + { + Name: "Groundhog", Power: 1, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet}}, + EffectText: "Faint: gain 1 Trumpet", + }, + { + Name: "Pied Tamarin", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, CostTrumpet: 1}}, + EffectText: "Play: spend 1 Trumpet to throw 2 Rocks", + }, + { + Name: "Chipmunk", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerSell, Action: ActionGainApple, Count: 2}}, + EffectText: "Sell: add 2 extra Apples to your hand", + }, + { + Name: "Cone Snail", Power: 1, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionPreventNextHit, Count: 2}}, + EffectText: "Faint: set aside — the next time a friendly pet is hit, prevent 2 damage", + }, + { + Name: "Bulldog", Power: 2, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple}}, + EffectText: "After Attacking: if it hasn't fainted, eat 1 Apple (once per round)", + }, + { + Name: "Opossum", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerSell, Action: ActionGainApple, Per: PerShopFaintPets}}, + EffectText: "Sell: add 1 extra Apple to your hand for each Faint pet in the shop", + }, + }, + { // Tier 2 + { + Name: "Black-Necked Stilt", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet}}, + EffectText: "Faint: gain 1 Trumpet", + }, + { + Name: "Lizard", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionSummonTop, Card: "bee"}}, + EffectText: "Hurt: add a Bee on top of your deck", + }, + { + Name: "Hercules Beetle", Power: 1, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerSell, Action: ActionApplesInPlay, Count: 3, MinRound: 3}}, + EffectText: "Sell: if it is round 3 or later, start the battle with 3 Apples in play", + }, + { + Name: "Stoat", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerSell, Action: ActionBuyTopFree}}, + EffectText: "Sell: buy the top card of the shop deck for free", + }, + { + Name: "Desert Rain Frog", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 2, CostTrumpet: 1}}, + EffectText: "Faint: spend 1 Trumpet to add 2 Bees on top of your deck", + }, + { + Name: "Honduran White Bat", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerUniqueFaintedHats}}, + EffectText: "Play: gain 1 Trumpet for each unique suit among friendly fainted pets", + }, + }, + { // Tier 3 + { + Name: "Guinea Fowl", Power: 3, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerHurt, Action: ActionGainTrumpet}}, + EffectText: "Hurt: gain 1 Trumpet", + }, + { + Name: "Surgeon Fish", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 3, CostTrumpet: 1}}, + EffectText: "Play: spend 1 Trumpet to add 3 Apples on top of your deck", + }, + { + Name: "Osprey", Power: 3, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionGainTrumpet}, + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"}, + }, + EffectText: "Faint: gain 1 Trumpet and add a Bee on top of your deck", + }, + { + Name: "Anteater", Power: 3, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"}, + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee"}, + }, + EffectText: "Faint: add an Apple, then a Bee, on top of your deck", + }, + { + Name: "Bear", Power: 4, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonBottom, Card: "bee", Target: "all"}}, + EffectText: "Faint: add a Bee to the bottom of every player's deck", + }, + { + Name: "Royal Flycatcher", Power: 1, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerEnemyFaintedPets}}, + EffectText: "Play: throw a Rock for each enemy fainted pet", + }, + { + Name: "Flea", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDrainTrumpet, Count: 3}}, + EffectText: "Faint: your opponent loses 3 Trumpets", + }, + }, + { // Tier 4 + { + Name: "Saiga Antelope", Power: 1, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerFaintedPets}}, + EffectText: "Play: gain 1 Trumpet for each friendly fainted pet", + }, + { + Name: "Vaquita", Power: 2, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionDoubleTrumpets, Cap: 4}}, + EffectText: "Play: double your Trumpets (up to 4 gained)", + }, + { + Name: "Poison Dart Frog", Power: 2, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionBeeRocks, Count: 2}}, + EffectText: "Faint: set aside — each time you play a Bee, throw 2 Rocks", + }, + { + Name: "Manta Ray", Power: 4, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerShopStart, Action: ActionFirstBuyFree, Count: 4}}, + EffectText: "Shop start: if you have 4 or fewer pets, your first Buy is free", + }, + { + Name: "Slug", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{ + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 2}, + {Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"}, + }, + EffectText: "Faint: add 2 Bees, then an Apple, on top of your deck", + }, + { + Name: "Cockatoo", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionRevealForApples}}, + EffectText: "Buy: reveal another pet in your hand — gain Apples equal to its Power", + }, + { + Name: "Manatee", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{ + {Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, Target: "self"}, + {Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 4}, + }, + EffectText: "Play: throw 2 Rocks at itself, then add 4 Apples on top of your deck", + }, + }, + { // Tier 5 + { + Name: "Nyala", Power: 4, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGainTrumpet, Count: 2}}, + EffectText: "Faint: gain 2 Trumpets", + }, + { + Name: "Nurse Shark", Power: 3, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}}, + EffectText: "Play: spend up to 3 Trumpets to throw twice as many Rocks", + }, + { + Name: "Giant Isopod", Power: 4, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionFeedOnPlay, Count: 2}}, + EffectText: "Faint: set aside — each time you play a pet, spend 1 Trumpet to feed it 2 Apples", + }, + { + Name: "Blue-Ringed Octopus", Power: 4, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainApple, Per: PerBuysThisRound}}, + EffectText: "Buy: add an Apple to your hand for each Buy you've made this round", + }, + { + Name: "Raccoon", Power: 3, Suits: []Suit{SuitBlue, SuitYellow}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStealPerk}}, + EffectText: "Play: steal the enemy pet's Perk onto the top of your deck", + }, + { + Name: "Fire Ant", Power: 2, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 4}}, + EffectText: "Faint: add 4 Apples on top of your deck", + }, + { + Name: "Macaque", Power: 2, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionRecyclePerkApples, Count: 4}}, + EffectText: "Faint: put this pet's Perk and up to 4 of its Apples on top of your deck", + }, + }, + { // Tier 6 + { + Name: "Highland Cow", Power: 3, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainTrumpet, Per: PerPower}}, + EffectText: "Play: gain Trumpets equal to this pet's Power", + }, + { + Name: "Wildebeest", Power: 6, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf, CostTrumpet: 3}}, + EffectText: "Play: spend 3 Trumpets to prevent all damage the first time it is hit", + }, + { + Name: "Grizzly Bear", Power: 5, Suits: []Suit{SuitYellow, SuitRed}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerFaintedPets}}, + EffectText: "Play: throw a Rock for each friendly fainted pet", + }, + { + Name: "Catfish", Power: 5, Suits: []Suit{SuitBlue, SuitRed}, + Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionReactivateBuys}}, + EffectText: "Battle Prep: reactivate every Buy ability on the pets in your hand", + }, + { + Name: "Komodo", Power: 6, Suits: []Suit{SuitYellow, SuitBlue}, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShuffleApples, Count: 6, Condition: ConditionFirstPet}}, + EffectText: "Play: if this is your first pet, shuffle 6 Apples into your deck", + }, + { + Name: "Bird of Paradise", Power: 4, Suits: []Suit{SuitRed, SuitYellow}, + Effects: []Effect{ + {Trigger: TriggerBuy, Action: ActionApplesInPlay, Count: 2}, + {Trigger: TriggerBuy, Action: ActionStartTrumpets, Count: 2}, + }, + EffectText: "Buy: start the next battle with 2 Apples and 2 Trumpets in play", + }, + { + Name: "German Shepherd", Power: 5, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{{Trigger: TriggerFaint, Action: ActionGuardRetriever, Count: 5}}, + EffectText: "Faint: set aside — your Golden Retriever prevents 5 damage the first time it is hit", + }, + }, +} + +// goldenFoodTiers defines the Golden pack's food cards. Only Avocado (tier 3) +// exists so far. +var goldenFoodTiers = [MaxRounds][]foodTemplate{ + {}, {}, // Tiers 1-2 + { // Tier 3 + { + Name: "Avocado", Food: FoodAvocado, Copies: 2, + Effects: []Effect{{Trigger: TriggerBuy, Action: ActionSetAside}}, + EffectText: "Buy: set aside — later discard it instead of a coin to buy a card", + }, + }, + { // Tier 4 + { + Name: "Potato", Food: FoodPotato, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionPreventSelf, Count: 2, Cap: 2}}, + EffectText: "The first two times this pet is hit, prevent 2 damage", + }, + }, + { // Tier 5 + { + Name: "Durian", Food: FoodDurian, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStripApples}}, + EffectText: "Play: discard all enemy Apples in play", + }, + }, + { // Tier 6 + { + Name: "Tomato", Food: FoodTomato, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 4}}, + EffectText: "Play: throw 4 Rocks", + }, + }, +} + // newCardID mints a unique card ID within the game. func (g *Game) newCardID() string { g.NextCardID++ @@ -487,6 +856,8 @@ func (g *Game) newCardID() string { // back to it so this is the single seam future packs plug their cards into. func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTemplate) { switch pack { + case "golden": + return &goldenPetTiers, &goldenFoodTiers default: // turtle (and the placeholder packs, until they ship) return &petTiers, &foodTiers } @@ -556,11 +927,13 @@ func Catalog() []Card { return cards } -// cardByName mints a fresh instance of the named pet or food from its -// template (pets take their first printed suit). Returns false if unknown. +// cardByName mints a fresh instance of the named pet or food from the current +// pack's templates (pets take their first printed suit). Returns false if +// unknown. func (g *Game) cardByName(name string) (Card, bool) { - for tierIdx := range petTiers { - for _, t := range petTiers[tierIdx] { + pets, foods := packTiers(g.Pack) + for tierIdx := range pets { + for _, t := range pets[tierIdx] { if t.Name == name { suit := SuitRed if len(t.Suits) > 0 { @@ -572,7 +945,7 @@ func (g *Game) cardByName(name string) (Card, bool) { }, true } } - for _, f := range foodTiers[tierIdx] { + for _, f := range foods[tierIdx] { if f.Name == name { return Card{ ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1, @@ -608,3 +981,18 @@ func (g *Game) newBee() Card { Temporary: true, } } + +// newGoldenRetriever mints the Golden pack's supply pet: a temporary, +// unbuyable pet whose base Power equals the Trumpets that summoned it. Those +// Trumpets can't be added to or removed, so it takes no auras and eats no +// apples (documented no-op today: tiers 1-3 have no auras). +func (g *Game) newGoldenRetriever(power int) Card { + return Card{ + ID: g.newCardID(), + Kind: KindPet, + Name: "Golden Retriever", + Power: power, + Temporary: true, + EffectText: "Its Power equals its Trumpets, which can't be changed.", + } +} diff --git a/internal/game/game.go b/internal/game/game.go index d40280d..2d6ab3b 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -49,6 +49,23 @@ type Player struct { // TripledThisRound records whether the player used the Triple (trade-in) // action during the current round's shop (Bison's Battle Prep). TripledThisRound bool `json:"tripledThisRound"` + // Avocados (Golden pack) is the player's stash of set-aside Avocado tokens, + // each discardable in place of a coin to buy a card. It persists across + // rounds and is not part of the deck. + Avocados int `json:"avocados,omitempty"` + // PendingApplesInPlay (Golden pack) is apples banked this round by a sold + // Hercules Beetle; the next battle starts the player's first pet with that + // many apples, then it resets. + PendingApplesInPlay int `json:"pendingApplesInPlay,omitempty"` + // FirstBuyFree (Golden pack: Manta Ray) waives the gold cost of the + // player's first Buy this round; set at shop start, cleared when used. + FirstBuyFree bool `json:"firstBuyFree,omitempty"` + // BuysThisRound (Golden pack: Blue-Ringed Octopus) counts Buy actions the + // player has taken in the current shop round. + BuysThisRound int `json:"buysThisRound,omitempty"` + // 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"` // 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 @@ -81,6 +98,15 @@ type PendingTrade struct { Options [2]Card `json:"options"` } +// PendingReveal is an in-progress Cockatoo buy (Golden pack): the buyer must +// reveal another pet in their hand, gaining apples equal to its power. Options +// lists the card ids they may reveal. +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 +} + // Game is the complete authoritative state. It is a pure state machine: no // goroutines, no clocks, no I/O. Callers are responsible for locking. type Game struct { @@ -101,24 +127,104 @@ type Game struct { // it put. PrioritySeat int `json:"prioritySeat"` Pending *PendingTrade `json:"pending,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` // most recent battle + // 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 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. Log []LogEntry `json:"log,omitempty"` LogSeq int `json:"logSeq"` // last assigned entry sequence number + // --- Golden pack: resumable battle (Nurse Shark's mid-battle choice) --- + // A battle is deterministic given its decks, the recorded dice tape, and the + // recorded decisions, so it can be re-run from scratch each time a decision + // is made. BattleCardBase snapshots NextCardID at battle start so re-runs + // mint identical ephemeral card ids. + BattleDice []int `json:"battleDice,omitempty"` + BattleDecisions []int `json:"battleDecisions,omitempty"` + BattleCardBase int `json:"battleCardBase,omitempty"` + PendingBattle *PendingBattleDecision `json:"pendingBattle,omitempty"` + // RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil // (including after loading from storage) means a fair random roll. RollDie func() int `json:"-"` + + // Transient per-run battle state (not serialized): the tape cursors and the + // rollout auto-decide flag. + battleRollCursor int + battleDecisionCursor int + autoBattleDecide bool +} + +// PendingBattleDecision is a choice a player owes mid-battle (Golden pack: Nurse +// Shark). The battle suspends until BattleChoose supplies a value in [Min, Max]. +type PendingBattleDecision struct { + Seat int `json:"seat"` + Kind string `json:"kind"` // "nurseShark" + PetName string `json:"petName"` // for the prompt + Min int `json:"min"` + Max int `json:"max"` + Trumpets int `json:"trumpets"` // the side's current Trumpet pool +} + +// battleDraw returns a random value in [0, n), replaying from the recorded +// battle tape when re-running a battle and recording fresh draws otherwise. +// This keeps re-runs (after a mid-battle decision) deterministic across every +// source of battle randomness — rock dice and Komodo's apple shuffle alike. +func (g *Game) battleDraw(n int) int { + if g.battleRollCursor < len(g.BattleDice) { + v := g.BattleDice[g.battleRollCursor] + g.battleRollCursor++ + return v + } + var v int + switch { + case n <= 0: + v = 0 + case n == 3 && g.RollDie != nil: + v = g.RollDie() // test override applies to rock dice + default: + v = randInt(n) + } + g.BattleDice = append(g.BattleDice, v) + g.battleRollCursor++ + return v } // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. -func (g *Game) rollRockDie() int { - if g.RollDie != nil { - return g.RollDie() +func (g *Game) rollRockDie() int { return g.battleDraw(3) } + +// decideBattle resolves a mid-battle decision. In rollouts it auto-picks; when +// replaying it reads the recorded decision; otherwise it signals a suspend by +// returning a non-nil pending for the caller to surface. +func (g *Game) decideBattle(pd PendingBattleDecision) (int, *PendingBattleDecision) { + if g.autoBattleDecide { + return autoBattleChoice(pd), nil } - return randInt(3) + if g.battleDecisionCursor < len(g.BattleDecisions) { + v := clampInt(g.BattleDecisions[g.battleDecisionCursor], pd.Min, pd.Max) + g.battleDecisionCursor++ + return v, nil + } + return 0, &pd +} + +// autoBattleChoice is the fixed policy used in rollouts and by bots: for Nurse +// Shark, spend as many Trumpets as allowed (more rocks is better). +func autoBattleChoice(pd PendingBattleDecision) int { + return pd.Max +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v } var ( @@ -302,15 +408,24 @@ func (g *Game) start() { func (g *Game) startShopRound() { g.Phase = PhaseShop g.Pending = nil + g.PendingReveal = nil for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false p.TripledThisRound = false + p.FirstBuyFree = false + p.BuysThisRound = 0 } g.ShopRow = make([]Card, ShopRowSize) for i := range g.ShopRow { g.ShopRow[i] = g.drawFromTier(g.Round) } + // Shop-start deck effects fire now (Golden pack: Manta Ray's free first buy). + for _, p := range g.Players { + for _, c := range slices.Clone(p.Deck) { + g.applyShopTrigger(p, c, TriggerShopStart) + } + } // The priority-token holder shops first. g.Turn = g.PrioritySeat g.logf(-1, "🛒", "Round %d — shop opens (%s goes first).", g.Round, g.Players[g.PrioritySeat].Name) @@ -343,32 +458,90 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) { if g.Pending != nil { return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction) } + if g.PendingReveal != nil { + return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction) + } return p, nil } // Buy spends one coin to take the card at rowIdx into the player's deck. // The slot refills from the current round's tier deck. Buying is the only -// action that costs gold. +// action that costs gold (or, in the Golden pack, an Avocado — see BuyAvocado). func (g *Game) Buy(playerID string, rowIdx int) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } - if p.Coins <= 0 { + // Golden pack: Manta Ray waives the cost of the first buy this round. + free := p.FirstBuyFree + if !free && p.Coins <= 0 { return ErrNoCoins } + if err := g.validRow(rowIdx); err != nil { + return err + } + if free { + p.FirstBuyFree = false + } else { + p.Coins-- + } + p.BuysThisRound++ + verb := "bought" + if free { + verb = "bought (free)" + } + g.finishBuy(p, rowIdx, verb) + g.advanceAfterBuy() + return nil +} + +// BuyAvocado (Golden pack) takes the card at rowIdx into the player's deck by +// discarding a set-aside Avocado instead of spending a coin. +func (g *Game) BuyAvocado(playerID string, rowIdx int) error { + p, err := g.requireShopTurn(playerID) + if err != nil { + return err + } + if p.Avocados <= 0 { + return fmt.Errorf("%w: no Avocado to discard", ErrInvalidAction) + } + if err := g.validRow(rowIdx); err != nil { + return err + } + p.Avocados-- + p.BuysThisRound++ + g.finishBuy(p, rowIdx, "bought (with an Avocado)") + g.advanceAfterBuy() + return nil +} + +// 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 { + return + } + g.advanceShopTurn() +} + +// validRow checks that a shop slot holds a card. +func (g *Game) validRow(rowIdx int) error { if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" { return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction) } - p.Coins-- + return nil +} + +// finishBuy takes the row card into the deck, logs the (public) buy, refills +// the slot, and fires the card's Buy effect — shared by every buy path. The +// how phrase describes how it was paid for. Payment is the caller's job. +func (g *Game) finishBuy(p *Player, rowIdx int, how string) { bought := g.ShopRow[rowIdx] p.Deck = append(p.Deck, bought) g.addLog(LogEntry{Seat: p.Seat, Icon: "🛒", Kind: LogBuy, Source: bought.ID, CardName: bought.Name, - Text: fmt.Sprintf("%s bought %s %s.", p.Name, article(bought.Name), bought.Name)}) + Text: fmt.Sprintf("%s %s %s %s.", p.Name, how, article(bought.Name), bought.Name)}) g.ShopRow[rowIdx] = g.drawFromTier(g.Round) g.applyShopTrigger(p, bought, TriggerBuy) - g.advanceShopTurn() - return nil } // Sell converts any number (>=1) of the player's cards: each becomes an @@ -402,11 +575,85 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { switch e.Action { case ActionGainApple: n := e.count() + switch e.Per { + case PerShopFaintPets: + n *= g.countShopFaintPets() + case PerBuysThisRound: + n *= p.BuysThisRound + } + if n <= 0 { + continue + } for range n { p.Deck = append(p.Deck, g.newApple()) } g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: n, Text: fmt.Sprintf("%s adds %d apple%s to %s's deck.", c.Name, n, plural(n), p.Name)}) + case ActionFirstBuyFree: + // Golden pack: Manta Ray, at shop start. + if trigger == TriggerShopStart && p.PetCount() <= e.count() { + p.FirstBuyFree = true + g.logf(p.Seat, "🛒", "%s readies %s's first buy for free.", c.Name, p.Name) + } + case ActionRevealForApples: + // Golden pack: Cockatoo — reveal another pet in hand for apples. Only + // during a real shop turn (skipped when Catfish reactivates buys at + // battle prep), and needs at least one other pet. + if g.Phase != PhaseShop { + continue + } + var opts []string + for _, dc := range p.Deck { + if dc.IsPet() && dc.ID != c.ID { + opts = append(opts, dc.ID) + } + } + if len(opts) > 0 { + g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts} + } + case ActionApplesInPlay: + // Golden pack: apples-in-play banked when sold (Hercules Beetle) or + // bought (Bird of Paradise). Monkey's battle-prep version is resolved + // inside resolveBattle, so battlePrep is excluded here. + if trigger == TriggerSell || trigger == TriggerBuy { + p.PendingApplesInPlay += e.count() + g.logf(p.Seat, "🍎", "%s sets up %d apple%s in play for the battle.", c.Name, e.count(), plural(e.count())) + } + case ActionStartTrumpets: + // Golden pack: Bird of Paradise banks Trumpets for the next battle. + p.PendingTrumpets += e.count() + g.logf(p.Seat, "🎺", "%s sets up %d Trumpet%s in play for the battle.", c.Name, e.count(), plural(e.count())) + case ActionReactivateBuys: + // Golden pack: Catfish re-fires every pet's Buy ability at battle prep. + if trigger == TriggerBattlePrep { + for _, pet := range slices.Clone(p.Deck) { + if pet.IsPet() && pet.ID != c.ID { + g.applyShopTrigger(p, pet, TriggerBuy) + } + } + } + case ActionBuyTopFree: + // Golden pack: Stoat pulls the top of the current tier deck for free. + top := g.drawFromTier(g.Round) + if top.ID == "" { + continue + } + 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 grabs %s %s from the shop deck for free — its buy ability triggers.", c.Name, article(top.Name), top.Name)}) + } else { + g.logf(p.Seat, "🛒", "%s grabs a card from the shop deck for free.", c.Name) + } + g.applyShopTrigger(p, top, TriggerBuy) + case ActionSetAside: + // Golden pack: Avocado is diverted out of the deck into the + // persistent set-aside stash (the caller already appended it). + if idx := p.cardIndex(c.ID); idx >= 0 { + p.Deck = slices.Delete(p.Deck, idx, idx+1) + } + p.Avocados++ + g.logf(p.Seat, "🥑", "%s sets %s aside.", p.Name, c.Name) case ActionRefreshGold: p.Coins = min(p.Coins+e.count(), CoinsPerRound) g.logf(p.Seat, "🪙", "%s refreshes %s's coins.", c.Name, p.Name) @@ -466,6 +713,24 @@ func hasBuyEffect(c Card) bool { return false } +// countShopFaintPets counts pets currently in the shop row that carry a Faint +// effect (Opossum's Sell payout, Golden pack). +func (g *Game) countShopFaintPets() int { + n := 0 + for _, c := range g.ShopRow { + if c.ID == "" || !c.IsPet() { + continue + } + for _, e := range c.Effects { + if e.Trigger == TriggerFaint { + n++ + break + } + } + } + return n +} + // TradeStart trades three same-suit pets from the player's deck to reveal // the top two cards of the next tier's deck — a free action. The player must // then call TradeChoose before anything else happens. @@ -559,6 +824,34 @@ func (g *Game) TradeChoose(playerID string, pick int) error { } // Pets obtained via the Triple action trigger their Buy effects. g.applyShopTrigger(p, chosen, TriggerBuy) + g.advanceAfterBuy() + return nil +} + +// RevealChoose resolves a pending Cockatoo reveal (Golden pack): the buyer +// reveals one of their other pets and gains apples equal to its power. +func (g *Game) RevealChoose(playerID, cardID string) error { + if g.Phase != PhaseShop || g.PendingReveal == nil || g.PendingReveal.PlayerID != playerID { + return fmt.Errorf("%w: no reveal waiting on you", ErrInvalidAction) + } + if !slices.Contains(g.PendingReveal.Options, cardID) { + return fmt.Errorf("%w: reveal one of your other pets", ErrInvalidAction) + } + p := g.PlayerByID(playerID) + idx := p.cardIndex(cardID) + if idx < 0 { + return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) + } + revealed := p.Deck[idx] + n := revealed.Power + for range n { + p.Deck = append(p.Deck, g.newApple()) + } + // The reveal is public (the opponent learns the pet); the apple spawn rides + // on this entry so observers count it (Kind "" + Spawn "apple"). + g.addLog(LogEntry{Seat: p.Seat, Icon: "🦜", Source: revealed.ID, Spawn: "apple", Count: n, CardName: revealed.Name, + Text: fmt.Sprintf("%s reveals %s (power %d) — adds %d apple%s to hand.", p.Name, revealed.Name, revealed.Power, n, plural(n))}) + g.PendingReveal = nil g.advanceShopTurn() return nil } @@ -664,17 +957,49 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error { p.Deck = ordered p.Ready = true if g.allReady() { - g.resolveBattle() + g.startBattle() } return nil } +// startBattle enters the battle phase and resolves it. It snapshots the card-id +// base and clears the dice/decision tapes so the (possibly resumable) battle +// re-runs deterministically as decisions come in. +func (g *Game) startBattle() { + g.BattleCardBase = g.NextCardID + g.BattleDice = nil + g.BattleDecisions = nil + g.PendingBattle = nil + for _, p := range g.Players { + p.Ready = false + } + g.Phase = PhaseBattle + g.resolveBattle() +} + +// BattleChoose supplies a value for the pending mid-battle decision (Golden +// pack: Nurse Shark) and re-runs the battle from the recorded tape. +func (g *Game) BattleChoose(playerID string, value int) error { + if g.PendingBattle == nil { + return fmt.Errorf("%w: no battle decision pending", ErrInvalidAction) + } + if g.Players[g.PendingBattle.Seat].ID != playerID { + return ErrNotYourTurn + } + g.BattleDecisions = append(g.BattleDecisions, clampInt(value, g.PendingBattle.Min, g.PendingBattle.Max)) + g.resolveBattle() + return nil +} + // AcknowledgeBattle marks the player done reviewing the battle. When all // players acknowledge, the next round starts (or the game ends). func (g *Game) AcknowledgeBattle(playerID string) error { if g.Phase != PhaseBattle { return ErrWrongPhase } + if g.PendingBattle != nil { + return fmt.Errorf("%w: a battle decision is still pending", ErrInvalidAction) + } p := g.PlayerByID(playerID) if p == nil { return errors.New("unknown player") diff --git a/internal/game/golden45_test.go b/internal/game/golden45_test.go new file mode 100644 index 0000000..e27ef1d --- /dev/null +++ b/internal/game/golden45_test.go @@ -0,0 +1,358 @@ +package game + +import "testing" + +// --- Nurse Shark: the mid-battle decision channel --- + +// Nurse Shark suspends the battle to ask how many Trumpets to spend, then +// resumes and throws two rocks per Trumpet spent. +func TestNurseSharkSuspendsAndResumes(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // each rock deals 2 + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")}, // Nyala faint: +2 trumpets + []Card{g.pet("Big", 4), g.pet("Tank", 6)}, + ) + // Nyala trades with Big (banking 2 Trumpets), then Nurse Shark enters and + // the battle suspends on its choice. + if g.PendingBattle == nil { + t.Fatal("expected the battle to suspend on Nurse Shark's choice") + } + if g.PendingBattle.Seat != 0 || g.PendingBattle.Max != 2 || g.PendingBattle.Kind != "nurseShark" { + t.Fatalf("unexpected pending decision: %+v", g.PendingBattle) + } + if res.WinnerSeat != -1 { + t.Fatalf("a suspended battle has no winner yet, got %d", res.WinnerSeat) + } + // Spend both Trumpets: 4 rocks (2+2+2+2 = 8) kill the 6-power Tank. + if err := g.BattleChoose(g.Players[0].ID, 2); err != nil { + t.Fatal(err) + } + if g.PendingBattle != nil { + t.Fatalf("battle should have completed: %+v", g.PendingBattle) + } + spends := 0 + for _, ev := range eventsOfType(g.Battle, "trumpet") { + if ev.Count < 0 { + spends++ + } + } + if spends != 1 { + t.Fatalf("expected one trumpet spend, got %d", spends) + } + rocks := eventsOfType(g.Battle, "rock") + if len(rocks) != 1 || len(rocks[0].Dice) != 4 || !rocks[0].TargetDied { + t.Fatalf("Nurse Shark should throw 4 rocks (2 per Trumpet) and kill Tank: %+v", rocks) + } + if g.Battle.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) + } +} + +// Choosing to spend zero Trumpets throws no rocks. +func TestNurseSharkSpendZero(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } + forceBattle(t, g, + []Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Nurse Shark")}, + []Card{g.pet("Big", 4), g.pet("Tank", 6)}, + ) + if g.PendingBattle == nil { + t.Fatal("expected a pending decision") + } + if err := g.BattleChoose(g.Players[0].ID, 0); err != nil { + t.Fatal(err) + } + if len(eventsOfType(g.Battle, "rock")) != 0 { + t.Fatal("spending zero Trumpets should throw no rocks") + } +} + +// With no Trumpets, Nurse Shark's choice is trivial (max 0) and the rollout +// path auto-resolves without suspending. +func TestNurseSharkAutoResolvesInRollout(t *testing.T) { + res := SimulateBattle(1, 0, + []Card{cardWithName("Nurse Shark", 3, []Effect{{Trigger: TriggerPlay, Action: ActionSpendRocks, Count: 3}})}, + []Card{{ID: "x", Kind: KindPet, Name: "Foe", Power: 2}}, + func() int { return 2 }, + ) + if res == nil || res.WinnerSeat < -1 { + t.Fatalf("rollout should complete without suspending: %+v", res) + } +} + +func cardWithName(name string, power int, eff []Effect) Card { + return Card{ID: "t-" + name, Kind: KindPet, Name: name, Power: power, Effects: eff} +} + +// --- Other tier 4-5 battle mechanics --- + +// Vaquita doubles the side's Trumpets, capped at 4 gained. +func TestVaquitaDoublesTrumpets(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Vaquita")}, // Nyala faint: +2 + []Card{g.pet("Big", 4), g.pet("Wall", 20)}, + ) + // Nyala trades with Big (+2 Trumpets), Vaquita enters and doubles to 4. + var gain *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 { + // two events: Nyala's +2 gain and Vaquita's +2 double. + gain = &res.Events[i] + } + } + if gain == nil { + t.Fatalf("Vaquita should double 2 Trumpets into 2 more: %+v", eventsOfType(res, "trumpet")) + } +} + +// Manatee throws rocks at itself. +func TestManateeSelfRock(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } // each rock deals 1 + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Manatee")}, + []Card{g.pet("Wall", 20)}, + ) + var self *BattleEvent + for i, ev := range res.Events { + if ev.Type == "rock" && ev.Seat == 0 && ev.Target == 0 { + self = &res.Events[i] + } + } + if self == nil || self.Roll != 2 { + t.Fatalf("Manatee should pelt itself for 2: %+v", eventsOfType(res, "rock")) + } + // It also stacks 4 apples on top of the deck. + apples := 0 + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Apple" { + apples++ + } + } + if apples != 4 { + t.Fatalf("Manatee should add 4 apples, got %d", apples) + } +} + +// Poison Dart Frog, once set aside, throws rocks each time a Bee is played. +func TestPoisonDartFrogBeeRocks(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Poison Dart Frog"), g.newBee()}, + []Card{g.pet("Killer", 2), g.pet("Tank", 1)}, + ) + // Frog trades with Killer (set aside); the Bee enters and triggers 2 rocks + // (roll 4) that kill the 1-power Tank. + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied { + t.Fatalf("playing a Bee should throw Poison Dart Frog's 2 rocks: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// Giant Isopod, once set aside, spends a Trumpet to feed each played pet. +func TestGiantIsopodFeedsOnPlay(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Nyala"), g.goldenPet(t, "Giant Isopod"), g.pet("Ally", 1)}, + []Card{g.pet("K1", 4), g.pet("K2", 4), g.pet("Wall", 1)}, + ) + // Nyala banks 2 Trumpets, Isopod faints (set aside), then Ally enters and is + // fed 2 apples (spending 1 Trumpet), reaching power 3 to beat the Wall. + fed := false + for _, ev := range eventsOfType(res, "eat") { + if ev.Seat == 0 && ev.Bonus == 2 { + fed = true + } + } + if !fed { + t.Fatalf("Giant Isopod should feed Ally 2 apples: %+v", eventsOfType(res, "eat")) + } + if res.WinnerSeat != 0 { + t.Fatalf("fed Ally should win, got %d", res.WinnerSeat) + } +} + +// Raccoon steals the enemy perk onto the top of its own deck. +func TestRaccoonStealsPerk(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Raccoon")}, + []Card{g.realFood(t, "Melon"), g.pet("Foe", 2)}, + ) + stole := false + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Melon" && ev.Seat == 0 { + stole = true + } + } + if !stole { + t.Fatalf("Raccoon should summon the stolen Melon onto its own deck: %+v", eventsOfType(res, "summon")) + } +} + +// Macaque recycles its perk and apples onto the top of the deck. +func TestMacaqueRecyclesPerkAndApples(t *testing.T) { + g, _, _ := testGame(t) + // Macaque wears Honey (a perk) and carries an apple, then faints. + res := forceBattle(t, g, + []Card{g.realFood(t, "Honey"), g.newApple(), g.goldenPet(t, "Macaque")}, + []Card{g.pet("Big", 9)}, + ) + // On faint it puts the apple and the Honey perk back on top of the deck. + var sawApple, sawHoney bool + for _, ev := range eventsOfType(res, "summon") { + if ev.Card == nil || ev.Seat != 0 { + continue + } + switch ev.Card.Name { + case "Apple": + sawApple = true + case "Honey": + sawHoney = true + } + } + if !sawApple || !sawHoney { + t.Fatalf("Macaque should recycle its apple and Honey perk: %+v", eventsOfType(res, "summon")) + } +} + +// Potato prevents 2 damage on each of the first two hits. +func TestPotatoPreventsTwice(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenFood(t, "Potato"), g.pet("Hero", 5)}, + []Card{g.pet("A", 3), g.pet("B", 3)}, + ) + prevents := eventsOfType(res, "prevent") + if len(prevents) != 2 { + t.Fatalf("Potato should prevent damage twice, got %d: %+v", len(prevents), prevents) + } + for _, p := range prevents { + if p.Count != 2 || p.Seat != 0 { + t.Fatalf("each prevention should shave 2 for seat 0: %+v", p) + } + } + if res.WinnerSeat != 0 { + t.Fatalf("Hero should survive both hits and win, got %d", res.WinnerSeat) + } +} + +// Durian discards the enemy's apples in play. +func TestDurianStripsEnemyApples(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenFood(t, "Durian"), g.pet("Hero", 3)}, + []Card{g.newApple(), g.newApple(), g.pet("Foe", 1)}, // Foe buffed to power 3 + ) + // Without the strip, a power-3 Foe would trade evenly; stripped back to + // power 1, it just dies. + if res.WinnerSeat != 0 { + t.Fatalf("Durian should strip Foe's apples so Hero wins, got %d", res.WinnerSeat) + } + if len(eventsOfType(res, "strip")) == 0 { + t.Fatal("expected a strip event") + } +} + +// Saiga Antelope banks a Trumpet per friendly fainted pet on entry. +func TestSaigaTrumpetsPerFainted(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("F1", 1), g.pet("F2", 1), g.goldenPet(t, "Saiga Antelope")}, + []Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)}, + ) + // F1/F2 trade with K1/K2 (2 friendly faints), then Saiga banks 2 Trumpets. + var gain *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 { + gain = &res.Events[i] + } + } + if gain == nil { + t.Fatalf("Saiga should bank 2 Trumpets for 2 friendly faints: %+v", eventsOfType(res, "trumpet")) + } +} + +// --- Tier 4-5 shop mechanics --- + +// Manta Ray waives the cost of the first buy each round (with few pets). +func TestMantaRayFirstBuyFree(t *testing.T) { + g, p1, _ := goldenGame(t) + p1.Deck = append(p1.Deck, g.goldenPet(t, "Manta Ray")) + g.startShopRound() // re-open the shop with Manta Ray in the deck + if !p1.FirstBuyFree { + t.Fatal("Manta Ray should ready a free first buy at 4 or fewer pets") + } + g.Turn = 0 + coins := p1.Coins + g.ShopRow[0] = g.goldenPet(t, "Nyala") + if err := g.Buy(p1.ID, 0); err != nil { + t.Fatal(err) + } + if p1.Coins != coins { + t.Fatalf("the first buy should be free: coins %d -> %d", coins, p1.Coins) + } + if p1.FirstBuyFree { + t.Fatal("the free-buy flag should clear after use") + } + // The next buy costs a coin. + g.Turn = 0 + if err := g.Buy(p1.ID, 1); err != nil { + t.Fatal(err) + } + if p1.Coins != coins-1 { + t.Fatalf("the second buy should cost a coin: %d", p1.Coins) + } +} + +// Blue-Ringed Octopus grants an apple per buy made this round. +func TestOctopusApplesPerBuy(t *testing.T) { + g, p1, _ := goldenGame(t) + g.Turn = 0 + g.ShopRow[0] = g.goldenPet(t, "Nyala") // a plain first buy + if err := g.Buy(p1.ID, 0); err != nil { + t.Fatal(err) + } + g.Turn = 0 + g.ShopRow[1] = g.goldenPet(t, "Blue-Ringed Octopus") // the second buy + if err := g.Buy(p1.ID, 1); err != nil { + t.Fatal(err) + } + // Second buy: buysThisRound == 2 -> 2 apples. + if countApplesIn(p1.Deck) != 2 { + t.Fatalf("Octopus bought as the 2nd buy should add 2 apples, got %d", countApplesIn(p1.Deck)) + } +} + +// Cockatoo makes the buyer reveal a pet for apples equal to its power. +func TestCockatooReveal(t *testing.T) { + g, p1, _ := goldenGame(t) + beefy := g.pet("Beefy", 5) + p1.Deck = append(p1.Deck, beefy) + g.Turn = 0 + g.ShopRow[0] = g.goldenPet(t, "Cockatoo") + if err := g.Buy(p1.ID, 0); err != nil { + t.Fatal(err) + } + if g.PendingReveal == nil || g.PendingReveal.PlayerID != p1.ID { + t.Fatalf("buying Cockatoo should open a reveal: %+v", g.PendingReveal) + } + if g.Turn != 0 { + t.Fatalf("the turn should stay on the buyer during the reveal, got %d", g.Turn) + } + if err := g.RevealChoose(p1.ID, beefy.ID); err != nil { + t.Fatal(err) + } + if g.PendingReveal != nil { + t.Fatal("the reveal should be resolved") + } + if countApplesIn(p1.Deck) != 5 { + t.Fatalf("revealing a power-5 pet should add 5 apples, got %d", countApplesIn(p1.Deck)) + } +} diff --git a/internal/game/golden6_test.go b/internal/game/golden6_test.go new file mode 100644 index 0000000..50ce0be --- /dev/null +++ b/internal/game/golden6_test.go @@ -0,0 +1,173 @@ +package game + +import "testing" + +// Highland Cow banks Trumpets equal to its power on entry. +func TestHighlandCowTrumpetsFromPower(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Highland Cow")}, // power 3 + []Card{g.pet("Foe", 1)}, + ) + var gain *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 3 { + gain = &res.Events[i] + } + } + if gain == nil { + t.Fatalf("Highland Cow should bank 3 Trumpets (its power): %+v", eventsOfType(res, "trumpet")) + } +} + +// Wildebeest spends 3 Trumpets on entry to shield its first hit. +func TestWildebeestSpendsForShield(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Highland Cow"), g.goldenPet(t, "Wildebeest")}, // Cow banks 3 + []Card{g.pet("Chip", 1), g.pet("Hitter", 6)}, + ) + // Cow banks 3 Trumpets and trades with Chip, then dies to Hitter; Wildebeest + // enters, spends the 3 Trumpets for a shield, and blocks Hitter's blow. + spent := false + for _, ev := range eventsOfType(res, "trumpet") { + if ev.Seat == 0 && ev.Count == -3 { + spent = true + } + } + if !spent { + t.Fatalf("Wildebeest should spend 3 Trumpets: %+v", eventsOfType(res, "trumpet")) + } + if len(eventsOfType(res, "shield")) == 0 { + t.Fatal("Wildebeest should block a hit with its shield") + } + if res.WinnerSeat != 0 { + t.Fatalf("Wildebeest should survive the block and win, got %d", res.WinnerSeat) + } +} + +// Grizzly Bear throws a rock per friendly fainted pet. +func TestGrizzlyRocksPerFainted(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } + res := forceBattle(t, g, + []Card{g.pet("F1", 1), g.pet("F2", 1), g.goldenPet(t, "Grizzly Bear")}, + []Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Tank", 2)}, + ) + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || len(rocks[0].Dice) != 2 || !rocks[0].TargetDied { + t.Fatalf("Grizzly should throw 2 rocks (one per friendly faint) and kill Tank: %+v", rocks) + } +} + +// Catfish reactivates every pet's Buy ability at battle prep. +func TestCatfishReactivatesBuys(t *testing.T) { + g, p1, _ := goldenGame(t) + p1.Deck = []Card{g.goldenPet(t, "Catfish"), g.realPet(t, "Otter")} // Otter buy: +1 apple + g.beginArrange() + if countApplesIn(p1.Deck) != 1 { + t.Fatalf("Catfish should re-fire Otter's buy for 1 apple, got %d", countApplesIn(p1.Deck)) + } +} + +// Komodo shuffles 6 apples into the deck when it is the first pet. +func TestKomodoShufflesApplesFirstPet(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Komodo")}, // power 6, first pet + []Card{g.pet("Foe", 1)}, + ) + apples := 0 + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Apple" && ev.Seat == 0 { + apples++ + } + } + if apples != 6 { + t.Fatalf("Komodo should shuffle 6 apples in as the first pet, got %d", apples) + } +} + +// Komodo does nothing when it is not the first pet played. +func TestKomodoNoShuffleWhenNotFirst(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.pet("Weak", 1), g.goldenPet(t, "Komodo")}, + []Card{g.pet("K", 1), g.pet("Foe", 1)}, + ) + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Apple" { + t.Fatal("Komodo should not shuffle apples when it isn't the first pet") + } + } +} + +// Bird of Paradise banks 2 apples-in-play and 2 Trumpets for the next battle. +func TestBirdOfParadiseBuy(t *testing.T) { + g, p1, _ := goldenGame(t) + g.ShopRow[0] = g.goldenPet(t, "Bird of Paradise") + g.Turn = 0 + if err := g.Buy(p1.ID, 0); err != nil { + t.Fatal(err) + } + if p1.PendingApplesInPlay != 2 || p1.PendingTrumpets != 2 { + t.Fatalf("Bird of Paradise should bank 2 apples + 2 Trumpets, got %d/%d", + p1.PendingApplesInPlay, p1.PendingTrumpets) + } +} + +// The banked Trumpets appear in the pool at battle start, and reset after. +func TestPendingTrumpetsSeedBattle(t *testing.T) { + g, p1, _ := testGame(t) + p1.PendingTrumpets = 2 + res := forceBattle(t, g, []Card{g.pet("Hero", 3)}, []Card{g.pet("Foe", 1)}) + var seeded *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count == 2 { + seeded = &res.Events[i] + } + } + if seeded == nil { + t.Fatalf("the battle should start with 2 Trumpets in the pool: %+v", eventsOfType(res, "trumpet")) + } + if p1.PendingTrumpets != 0 { + t.Fatalf("PendingTrumpets should reset after the battle, got %d", p1.PendingTrumpets) + } +} + +// German Shepherd guards the Golden Retriever's first hit. +func TestGermanShepherdGuardsRetriever(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "German Shepherd"), g.goldenPet(t, "Nyala")}, // Nyala banks 2 Trumpets + []Card{g.pet("BigA", 5), g.pet("BigB", 9)}, + ) + // German Shepherd faints (guard aside), Nyala faints (+2 Trumpets), then the + // Golden Retriever arrives and its first hit is shaved by 5. + var guarded *BattleEvent + for i, ev := range res.Events { + if ev.Type == "prevent" && ev.Seat == 0 && ev.Count == 5 { + guarded = &res.Events[i] + } + } + if guarded == nil { + t.Fatalf("the Golden Retriever's first hit should be guarded for 5: %+v", eventsOfType(res, "prevent")) + } +} + +// Tomato is a perk that throws 4 rocks on play. +func TestTomatoThrowsRocks(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 1 } + res := forceBattle(t, g, + []Card{g.goldenFood(t, "Tomato"), g.pet("Holder", 2)}, + []Card{g.pet("Tank", 3)}, + ) + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied { + t.Fatalf("Tomato should throw 4 rocks and kill the 3-power Tank: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} diff --git a/internal/game/golden_test.go b/internal/game/golden_test.go new file mode 100644 index 0000000..894a1f2 --- /dev/null +++ b/internal/game/golden_test.go @@ -0,0 +1,422 @@ +package game + +import "testing" + +// --- Golden pack test helpers --- + +// goldenGame builds a started 2-player game on the Golden pack, bypassing the +// lobby and the Playable gate (tiers 4-6 aren't printed yet, so the pack isn't +// selectable in a real lobby — but tiers 1-3 are fully exercisable here). +func goldenGame(t *testing.T) (*Game, *Player, *Player) { + t.Helper() + g := New() + g.Pack = "golden" + 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 +} + +// goldenPet mints a copy of a Golden pack pet (with effects) by name. +func (g *Game) goldenPet(t *testing.T, name string) Card { + t.Helper() + for tierIdx, tier := range goldenPetTiers { + 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 golden pet named %s", name) + return Card{} +} + +// goldenFood mints a Golden pack food (Avocado) by name. +func (g *Game) goldenFood(t *testing.T, name string) Card { + t.Helper() + for tierIdx, tier := range goldenFoodTiers { + 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 golden food named %s", name) + return Card{} +} + +// suitedPet mints a plain effect-less pet with a chosen suit. +func (g *Game) suitedPet(name string, power int, suit Suit) Card { + return Card{ID: g.newCardID(), Kind: KindPet, Name: name, Tier: 1, Power: power, Suit: suit} +} + +// --- Trumpets & Golden Retriever --- + +// A fainting Groundhog banks a Trumpet; with no cards left, the side fields a +// Golden Retriever whose Power equals its Trumpets — and it wins. +func TestGroundhogTrumpetThenGoldenRetriever(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Groundhog")}, // pow 1, faint: +1 trumpet + []Card{g.pet("Weak", 1)}, + ) + trumpets := eventsOfType(res, "trumpet") + if len(trumpets) != 1 || trumpets[0].Seat != 0 || trumpets[0].Count != 1 { + t.Fatalf("expected one +1 trumpet event for seat 0: %+v", trumpets) + } + var gr *BattleEvent + for i, ev := range res.Events { + if ev.Type == "reveal" && ev.Card != nil && ev.Card.Name == "Golden Retriever" { + gr = &res.Events[i] + } + } + if gr == nil { + t.Fatal("expected a Golden Retriever to be summoned") + } + if gr.Card.Power != 1 || gr.Count != 1 { + t.Fatalf("Golden Retriever should have Power 1 from 1 trumpet: power=%d count=%d", gr.Card.Power, gr.Count) + } + if res.WinnerSeat != 0 { + t.Fatalf("the Golden Retriever should win for seat 0, got %d", res.WinnerSeat) + } +} + +// Pied Tamarin spends a banked Trumpet on entry to throw 2 Rocks. +func TestPiedTamarinSpendsTrumpetForRocks(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // each rock deals 2 + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Groundhog"), g.goldenPet(t, "Pied Tamarin")}, + []Card{g.pet("Weak", 1), g.pet("Tank", 3)}, + ) + // Groundhog trades with Weak (+1 trumpet). Pied Tamarin enters, spends the + // trumpet, throws 2 rocks (2+2=4) and kills the 3-power Tank. + spends := 0 + for _, ev := range eventsOfType(res, "trumpet") { + if ev.Count < 0 { + spends++ + } + } + if spends != 1 { + t.Fatalf("expected exactly one trumpet spend, got %d: %+v", spends, eventsOfType(res, "trumpet")) + } + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied { + t.Fatalf("Pied Tamarin should throw 2 rocks (roll 4) and kill the Tank: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// Without a Trumpet, Pied Tamarin's paid Play effect does nothing. +func TestPiedTamarinNoTrumpetNoRocks(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Pied Tamarin")}, + []Card{g.pet("Tank", 3)}, + ) + if rocks := eventsOfType(res, "rock"); len(rocks) != 0 { + t.Fatalf("no trumpet means no rocks: %+v", rocks) + } +} + +// Honduran White Bat banks a Trumpet per distinct suit among friendly fainted +// pets. +func TestHonduranBatTrumpetPerFaintedSuit(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{ + g.suitedPet("Red", 1, SuitRed), + g.suitedPet("Blue", 1, SuitBlue), + g.goldenPet(t, "Honduran White Bat"), + }, + []Card{g.pet("K1", 1), g.pet("K2", 1), g.pet("Last", 1)}, + ) + // Red and Blue trade with K1/K2 (two distinct fainted suits). The Bat + // enters and banks 2 Trumpets. + var gain *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 0 && ev.Count > 0 { + gain = &res.Events[i] + } + } + if gain == nil || gain.Count != 2 { + t.Fatalf("Bat should bank 2 trumpets for 2 unique fainted suits: %+v", eventsOfType(res, "trumpet")) + } +} + +// Flea drains Trumpets from the opponent when it faints. +func TestFleaDrainsEnemyTrumpets(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Flea")}, // pow 2 + []Card{g.goldenPet(t, "Groundhog"), g.pet("Big", 5)}, // Groundhog banks a trumpet for seat 1 + ) + // Flea kills Groundhog (seat 1 +1 trumpet), survives, then dies to Big and + // drains that trumpet back down to 0. + var drain *BattleEvent + for i, ev := range res.Events { + if ev.Type == "trumpet" && ev.Seat == 1 && ev.Count < 0 { + drain = &res.Events[i] + } + } + if drain == nil { + t.Fatalf("Flea should drain a trumpet from seat 1: %+v", eventsOfType(res, "trumpet")) + } +} + +// --- Cone Snail --- + +// Cone Snail's faint sets aside a one-shot 2-damage prevention for the next +// friendly hit. +func TestConeSnailPreventsTwoDamage(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Cone Snail"), g.pet("Ally", 5)}, + []Card{g.pet("Big", 6)}, + ) + // Cone Snail dies to the 6 (Big takes 1). Ally enters; Big's next hit of 6 + // is shaved to 4, so Ally (5) survives and kills Big. + prevents := eventsOfType(res, "prevent") + if len(prevents) != 1 || prevents[0].Seat != 0 || prevents[0].Count != 2 { + t.Fatalf("expected one 2-damage prevention for seat 0: %+v", prevents) + } + clashes := eventsOfType(res, "clash") + final := clashes[len(clashes)-1] + if final.Damage[0] != 4 || !final.Died[1] { + t.Fatalf("Ally should take 4 (6-2) and Big should die: %+v", final) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// --- Bulldog --- + +// Bulldog eats an apple after attacking, but only once per battle. +func TestBulldogAfterAttackOncePerRound(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Bulldog")}, // pow 2 + []Card{g.pet("W1", 1), g.pet("W2", 1)}, + ) + eats := eventsOfType(res, "eat") + if len(eats) != 1 || eats[0].Seat != 0 || eats[0].Bonus != 1 { + t.Fatalf("Bulldog should eat exactly one apple across the battle: %+v", eats) + } + if res.WinnerSeat != 0 { + t.Fatalf("Bulldog should survive and win, got %d", res.WinnerSeat) + } +} + +// --- Bear --- + +// Bear's faint drops a Bee on the bottom of BOTH decks. +func TestBearBeeToBothDecks(t *testing.T) { + g, _, _ := testGame(t) + res := forceBattle(t, g, + []Card{g.goldenPet(t, "Bear")}, // pow 4 + []Card{g.pet("Big", 5)}, + ) + seats := map[int]bool{} + for _, ev := range eventsOfType(res, "summon") { + if ev.Card != nil && ev.Card.Name == "Bee" { + seats[ev.Seat] = true + } + } + if !seats[0] || !seats[1] { + t.Fatalf("Bear should summon a Bee onto both decks, saw seats %v", seats) + } +} + +// --- Royal Flycatcher --- + +// Royal Flycatcher throws one Rock per enemy pet already fainted. +func TestRoyalFlycatcherRocksPerEnemyFaint(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } + res := forceBattle(t, g, + []Card{g.pet("K1", 1), g.pet("K2", 1), g.goldenPet(t, "Royal Flycatcher")}, + []Card{g.pet("E1", 1), g.pet("E2", 1), g.pet("Tank", 3)}, + ) + // K1/K2 trade with E1/E2 (2 enemy faints). Royal Flycatcher enters and + // throws 2 rocks (2+2=4), killing the 3-power Tank. + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || len(rocks[0].Dice) != 2 || !rocks[0].TargetDied { + t.Fatalf("Royal Flycatcher should throw 2 rocks (one per enemy faint) and kill Tank: %+v", rocks) + } + if res.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", res.WinnerSeat) + } +} + +// --- Shop-time effects --- + +// Selling a Chipmunk yields its base apple plus 2 extra. +func TestChipmunkSellApples(t *testing.T) { + g, p1, p2 := goldenGame(t) + chip := g.goldenPet(t, "Chipmunk") + p1.Deck = append(p1.Deck, chip) + // Hand the turn back to seat 0 after seat 1 acts. + _ = p2 + g.Turn = 0 + if err := g.Sell(p1.ID, []string{chip.ID}); err != nil { + t.Fatal(err) + } + if countApplesIn(p1.Deck) != 3 { + t.Fatalf("selling a Chipmunk should add 3 apples (1 base + 2 extra), got %d", countApplesIn(p1.Deck)) + } +} + +// Opossum's sell adds an apple per Faint pet currently in the shop. +func TestOpossumSellApplesPerShopFaint(t *testing.T) { + g, p1, _ := goldenGame(t) + op := g.goldenPet(t, "Opossum") + p1.Deck = append(p1.Deck, op) + g.ShopRow = []Card{ + g.goldenPet(t, "Groundhog"), // faint + g.goldenPet(t, "Cone Snail"), // faint + g.goldenPet(t, "Bulldog"), // after-attack, no faint + g.goldenPet(t, "Pied Tamarin"), // play, no faint + } + g.Turn = 0 + if err := g.Sell(p1.ID, []string{op.ID}); err != nil { + t.Fatal(err) + } + // 1 base apple + 2 (one per faint pet in shop). + if countApplesIn(p1.Deck) != 3 { + t.Fatalf("selling Opossum with 2 shop faint pets should add 3 apples, got %d", countApplesIn(p1.Deck)) + } +} + +// Stoat's sell pulls the top of the current tier's shop deck for free. +func TestStoatSellBuysTopOfDeck(t *testing.T) { + g, p1, _ := goldenGame(t) + stoat := g.goldenPet(t, "Stoat") + p1.Deck = append(p1.Deck, stoat) + g.Turn = 0 + deckBefore := len(g.ShopDecks[g.Round-1]) + petsBefore := p1.PetCount() + if err := g.Sell(p1.ID, []string{stoat.ID}); err != nil { + t.Fatal(err) + } + if got := len(g.ShopDecks[g.Round-1]); got != deckBefore-1 { + t.Fatalf("Stoat should draw one card from the tier deck: before %d, after %d", deckBefore, got) + } + // Stoat itself became an apple, but a fresh pet came from the deck, so the + // pet count is unchanged (–1 Stoat, +1 grabbed pet, assuming a pet on top). + if p1.PetCount() < petsBefore { + t.Fatalf("Stoat should have replaced itself with a grabbed pet: before %d, after %d", petsBefore, p1.PetCount()) + } +} + +// Hercules Beetle banks apples-in-play only from round 3 on, and the battle +// consumes them onto the first pet. +func TestHerculesBeetleApplesInPlay(t *testing.T) { + g, p1, _ := goldenGame(t) + + // Round 2: no effect. + g.Round = 2 + hb2 := g.goldenPet(t, "Hercules Beetle") + p1.Deck = append(p1.Deck, hb2) + g.Turn = 0 + if err := g.Sell(p1.ID, []string{hb2.ID}); err != nil { + t.Fatal(err) + } + if p1.PendingApplesInPlay != 0 { + t.Fatalf("Hercules Beetle should do nothing before round 3, got %d", p1.PendingApplesInPlay) + } + + // Round 3: banks 3. + g.Round = 3 + hb3 := g.goldenPet(t, "Hercules Beetle") + p1.Deck = append(p1.Deck, hb3) + g.Turn = 0 + if err := g.Sell(p1.ID, []string{hb3.ID}); err != nil { + t.Fatal(err) + } + if p1.PendingApplesInPlay != 3 { + t.Fatalf("Hercules Beetle in round 3 should bank 3 apples-in-play, got %d", p1.PendingApplesInPlay) + } + + // The battle seeds those apples onto the first pet, then resets. + res := forceBattle(t, g, + []Card{g.pet("Hero", 3)}, + []Card{g.pet("Foe", 1)}, + ) + if preps := eventsOfType(res, "prep"); len(preps) != 3 { + t.Fatalf("expected 3 apples-in-play prep events, got %d", len(preps)) + } + if p1.PendingApplesInPlay != 0 { + t.Fatalf("PendingApplesInPlay should reset after the battle, got %d", p1.PendingApplesInPlay) + } +} + +// --- Avocado --- + +// Buying an Avocado sets it aside (not into the deck); a later buy can spend it +// instead of a coin. +func TestAvocadoBuyAndDiscardToBuy(t *testing.T) { + g, p1, _ := goldenGame(t) + g.ShopRow[0] = g.goldenFood(t, "Avocado") + g.Turn = 0 + coins := p1.Coins + if err := g.Buy(p1.ID, 0); err != nil { + t.Fatal(err) + } + if p1.Avocados != 1 { + t.Fatalf("buying an Avocado should set aside 1, got %d", p1.Avocados) + } + if p1.Coins != coins-1 { + t.Fatalf("buying an Avocado still costs a coin: before %d, after %d", coins, p1.Coins) + } + for _, c := range p1.Deck { + if c.Food == FoodAvocado { + t.Fatal("the Avocado should not be in the deck") + } + } + + // Now discard the Avocado to buy the card in slot 1, spending no coin. + g.Turn = 0 + deckLen := len(p1.Deck) + coins = p1.Coins + if err := g.BuyAvocado(p1.ID, 1); err != nil { + t.Fatal(err) + } + if p1.Avocados != 0 { + t.Fatalf("the discard should consume the Avocado, got %d", p1.Avocados) + } + if p1.Coins != coins { + t.Fatalf("a discard-buy should spend no coin: before %d, after %d", coins, p1.Coins) + } + if len(p1.Deck) != deckLen+1 { + t.Fatalf("the discard-buy should add a card to the deck: before %d, after %d", deckLen, len(p1.Deck)) + } +} + +// BuyAvocado is refused with no Avocado in the stash. +func TestBuyAvocadoRequiresToken(t *testing.T) { + g, p1, _ := goldenGame(t) + g.Turn = 0 + if err := g.BuyAvocado(p1.ID, 0); err == nil { + t.Fatal("expected an error buying with no Avocado") + } +} diff --git a/internal/game/packs.go b/internal/game/packs.go index 69bf32e..3b930ee 100644 --- a/internal/game/packs.go +++ b/internal/game/packs.go @@ -1,9 +1,9 @@ package game // Card packs are the selectable sets of pets and food a game is played with. -// Only the Turtle pack ships with real card data today; Golden and Unicorn are -// 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 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. // PackInfo describes one selectable pack. Playable gates whether a lobby may // choose it and start a game with it. @@ -20,7 +20,7 @@ const DefaultPack = "turtle" // Packs is the ordered catalog of packs shown in the lobby. var Packs = []PackInfo{ {ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true}, - {ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: false}, + {ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: true}, {ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false}, } diff --git a/internal/game/regression_test.go b/internal/game/regression_test.go new file mode 100644 index 0000000..347150b --- /dev/null +++ b/internal/game/regression_test.go @@ -0,0 +1,26 @@ +package game + +import "testing" + +// Regression (improvements.txt): a Crocodile set aside must still throw its +// last-pet volley when the enemy plays their final pet, even though the +// Crocodile's own side is already out of pets. Previously the "battle over" +// check fired first and swallowed the parting shot. +func TestCrocodileLastPetVolleyFiresWhenOwnerOut(t *testing.T) { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // 3 rocks -> 6 damage + res := forceBattle(t, g, + []Card{g.pet("Killer", 4), g.pet("Last", 3)}, // seat 0 plays its last pet + []Card{g.realPet(t, "Crocodile")}, // seat 1's Croc faints, sets aside + ) + // Killer trades with Crocodile (set aside). Seat 0 then plays its last pet, + // and the Crocodile's 3 rocks (roll 6) pelt it dead — turning a win into a + // mutual knockout. + rocks := eventsOfType(res, "rock") + if len(rocks) != 1 || rocks[0].Seat != 1 || rocks[0].Roll != 6 || !rocks[0].TargetDied { + t.Fatalf("Crocodile should volley the last pet for 6: %+v", rocks) + } + if res.WinnerSeat != -1 { + t.Fatalf("both sides should be out (draw), got winner %d", res.WinnerSeat) + } +} diff --git a/internal/game/sim.go b/internal/game/sim.go index b02b7e9..8d0e1ed 100644 --- a/internal/game/sim.go +++ b/internal/game/sim.go @@ -17,16 +17,26 @@ func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() {Name: "A", Seat: 0, Deck: append([]Card(nil), deckA...)}, {Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)}, }, + // Rollouts never pause for a mid-battle decision; a fixed policy resolves + // them (Golden pack: Nurse Shark). + autoBattleDecide: true, } - g.resolveBattle() + g.startBattle() return g.Battle } -// TierContents returns the full printed contents of a tier's shop deck — -// public information from the box. Cards carry placeholder IDs; they are -// reference data, not live instances. +// TierContents returns the full printed contents of a tier's shop deck for the +// default pack. Cards carry placeholder IDs; they are reference data, not live +// instances. func TierContents(tier int) []Card { - scratch := &Game{} + return TierContentsForPack(DefaultPack, tier) +} + +// TierContentsForPack returns a pack's printed tier contents — public +// information from the box. Used by the AI, which decides from a View that +// names its pack. +func TierContentsForPack(pack string, tier int) []Card { + scratch := &Game{Pack: pack} scratch.buildShopDecks() if tier < 1 || tier > len(scratch.ShopDecks) { return nil diff --git a/internal/game/view.go b/internal/game/view.go index 98157b3..32bbb39 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -13,7 +13,14 @@ type PlayerView struct { IsBot bool `json:"isBot,omitempty"` DeckSize int `json:"deckSize"` PetCount int `json:"petCount"` - Deck []Card `json:"deck,omitempty"` // self only + // Avocados is the player's set-aside Avocado token count (Golden pack). + // Public: buying an Avocado is a public shop event. + Avocados int `json:"avocados,omitempty"` + // FirstBuyFree / BuysThisRound (Golden pack) are self-only: they'd reveal + // hidden deck facts (a Manta Ray) to opponents. + FirstBuyFree bool `json:"firstBuyFree,omitempty"` + BuysThisRound int `json:"buysThisRound,omitempty"` + Deck []Card `json:"deck,omitempty"` // self only } // View is the full game state as seen by one player. @@ -41,9 +48,16 @@ type View struct { Players []PlayerView `json:"players"` // Pending is included for everyone so opponents see a trade is in // progress, but the revealed options are only shown to the trader. - Pending *PendingTrade `json:"pending,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` - WinnerSeat int `json:"winnerSeat"` + Pending *PendingTrade `json:"pending,omitempty"` + // PendingReveal (Golden pack: Cockatoo) is shown to everyone so opponents + // 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"` + // PendingBattle (Golden pack: Nurse Shark) is a mid-battle decision owed by + // one seat; public since the battle replay is public. + PendingBattle *PendingBattleDecision `json:"pendingBattle,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"` // Debug is set by the server when its DEBUG flag is on, unlocking the @@ -87,10 +101,13 @@ func (g *Game) ViewFor(playerID string) View { IsBot: p.IsBot, DeckSize: len(p.Deck), PetCount: p.PetCount(), + Avocados: p.Avocados, } if p.ID == playerID { v.YouSeat = p.Seat pv.Deck = p.Deck + pv.FirstBuyFree = p.FirstBuyFree + pv.BuysThisRound = p.BuysThisRound } v.Players = append(v.Players, pv) } @@ -101,6 +118,14 @@ func (g *Game) ViewFor(playerID string) View { } v.Pending = &pending } + if g.PendingReveal != nil { + reveal := *g.PendingReveal + if reveal.PlayerID != playerID { + reveal.Options = nil // hide which of the buyer's pets are eligible + } + v.PendingReveal = &reveal + } + v.PendingBattle = g.PendingBattle // 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 cca80a3..66f6fb1 100644 --- a/internal/server/bots.go +++ b/internal/server/bots.go @@ -147,12 +147,18 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error { switch a.Type { case "buy": return g.Buy(playerID, a.Row) + case "buyAvocado": + return g.BuyAvocado(playerID, a.Row) case "sell": return g.Sell(playerID, a.Cards) case "trade": return g.TradeStart(playerID, a.Cards) case "tradeChoose": return g.TradeChoose(playerID, a.Pick) + case "revealChoose": + return g.RevealChoose(playerID, a.CardID) + case "battleChoose": + return g.BattleChoose(playerID, a.Value) case "pass": return g.Pass(playerID) case "arrange": @@ -174,6 +180,9 @@ func botFallback(g *game.Game, playerID string) error { } switch g.Phase { case game.PhaseShop: + if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID { + return g.RevealChoose(playerID, g.PendingReveal.Options[0]) + } if g.Pending != nil && g.Pending.PlayerID == playerID { return g.TradeChoose(playerID, 0) } @@ -194,6 +203,9 @@ func botFallback(g *game.Game, playerID string) error { } return g.SubmitOrder(playerID, ids) case game.PhaseBattle: + if g.PendingBattle != nil && g.Players[g.PendingBattle.Seat].ID == playerID { + return g.BattleChoose(playerID, g.PendingBattle.Max) + } return g.AcknowledgeBattle(playerID) } return game.ErrInvalidAction diff --git a/internal/server/ws.go b/internal/server/ws.go index 1fe8840..c3f37e8 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -31,6 +31,8 @@ type clientMessage struct { Pack string `json:"pack"` // setPack Difficulty string `json:"difficulty"` // addBot Target string `json:"target"` // removePlayer (player ID) + Card string `json:"card"` // revealChoose (Cockatoo): pet card id + Value int `json:"value"` // battleChoose (Nurse Shark): Trumpets to spend } type serverMessage struct { @@ -140,12 +142,18 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) { } case "buy": err = g.Buy(c.playerID, msg.Row) + case "buyAvocado": + err = g.BuyAvocado(c.playerID, msg.Row) case "sell": err = g.Sell(c.playerID, msg.Cards) case "trade": err = g.TradeStart(c.playerID, msg.Cards) case "tradeChoose": err = g.TradeChoose(c.playerID, msg.Pick) + case "revealChoose": + err = g.RevealChoose(c.playerID, msg.Card) + case "battleChoose": + err = g.BattleChoose(c.playerID, msg.Value) case "pass": err = g.Pass(c.playerID) case "arrange": diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 406af92..50cc08c 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { createPortal } from 'react-dom' -import type { BattleEvent, Card, ClientMessage, GameView } from '../types' +import type { BattleEvent, Card, ClientMessage, GameView, PendingBattleDecision } from '../types' import { CardView } from './CardView' import { DiceRoll, ROLL_MS } from './DiceRoll' @@ -52,6 +52,8 @@ const EVENT_MS: Record = { heal: 900, setaside: 700, release: 500, + trumpet: 800, + prevent: 900, } const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length @@ -86,6 +88,25 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side break case 'reveal': { const s = sides[ev.seat!] + // The Golden Retriever is summoned straight into play, not flipped off + // the deck, so it doesn't decrement the stack. + if (ev.card?.name === 'Golden Retriever') { + const trumpets = ev.count ?? 0 + s.unit = { + card: ev.card!, + // Show the Trumpets that powered it, fanned like food tokens. + foods: Array.from({ length: trumpets }, (_, i) => ({ + id: `${ev.card!.id}-t${i}`, + kind: 'food' as const, + name: 'Trumpet', + food: 'trumpet', + })), + bonus: 0, + damage: 0, + dying: false, + } + break + } s.stack-- const card = ev.card! if (card.kind === 'food') { @@ -164,6 +185,14 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side } case 'shield': break // pure animation; no state change + case 'trumpet': + break // pure animation; the pool isn't drawn on the board + case 'prevent': { + // Cone Snail shaved damage off the hit; the reduced total rides along. + const u = sides[ev.seat!].unit + if (u) u.damage = ev.damageAfter ?? u.damage + break + } case 'setaside': if (ev.card) sides[ev.seat!].setAside.push(ev.card) break @@ -194,6 +223,11 @@ function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], st } case 'shield': return ev.seat === seat ? '🛡️' : null + case 'prevent': + return ev.seat === seat ? `🛡️ −${ev.count}` : null + case 'trumpet': + if (ev.seat !== seat) return null + return (ev.count ?? 0) >= 0 ? `🎺 +${ev.count}` : `🎺 ${ev.count}` case 'eat': return ev.seat === seat ? '🍎' : null case 'heal': @@ -519,37 +553,94 @@ export function BattlePhase({ view, send, step, setStep }: Props) { ) })()} - {done && ( -
-
- {draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'} -
- {!draw && ( -
- {view.players[battle.winnerSeat]?.name} wins{' '} - {'🏆'.repeat(battle.trophies)} + {done && view.pendingBattle ? ( + + ) : ( + done && ( +
+
+ {draw ? 'Draw!' : won ? 'Victory!' : 'Defeat…'}
- )} - {draw &&
No trophies awarded
} - {acked ? ( -

Waiting for opponent…

- ) : ( - - )} -
+ {!draw && ( +
+ {view.players[battle.winnerSeat]?.name} wins{' '} + {'🏆'.repeat(battle.trophies)} +
+ )} + {draw &&
No trophies awarded
} + {acked ? ( +

Waiting for opponent…

+ ) : ( + + )} +
+ ) )}
) } +// BattleDecision is the mid-battle prompt (Golden pack: Nurse Shark). The +// deciding player picks how many Trumpets to spend; the other player waits. +function BattleDecision({ + pd, + youSeat, + oppName, + send, +}: { + pd: PendingBattleDecision + youSeat: number + oppName: string + send: (msg: ClientMessage) => void +}) { + const [sent, setSent] = useState(false) + // Reset when a fresh decision arrives (e.g. a second Nurse Shark). + useEffect(() => setSent(false), [pd.seat, pd.trumpets, pd.max]) + if (pd.seat !== youSeat) { + return ( +
+

{oppName} is deciding {pd.petName}…

+
+ ) + } + return ( +
+
{pd.petName}
+
+ Spend Trumpets to throw 2 🪨 each — you hold {pd.trumpets} 🎺 +
+
+ {Array.from({ length: pd.max + 1 }, (_, n) => ( + + ))} +
+
+ ) +} + // clashDamageTaken computes how much damage a seat's pet took in the clash // at event index `idx` (its damage total there minus its total beforehand). function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): number { diff --git a/web/src/components/EventLog.tsx b/web/src/components/EventLog.tsx index a91df4d..cfb4df9 100644 --- a/web/src/components/EventLog.tsx +++ b/web/src/components/EventLog.tsx @@ -24,6 +24,8 @@ const BATTLE_ICONS: Record = { heal: '💚', setaside: '🃏', release: '↩️', + trumpet: '🎺', + prevent: '🛡️', } // 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 6161714..43a3a79 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -39,8 +39,13 @@ interface Props { export function ShopPhase({ view, you, send }: Props) { const [selected, setSelected] = useState([]) const [confirmPass, setConfirmPass] = useState(false) + // When set, the next buy is paid by discarding an Avocado instead of a coin + // (Golden pack). It also kicks in automatically when out of coins. + const [useAvocado, setUseAvocado] = useState(false) const myTurn = view.turn === view.youSeat && !you.ready - const canBuy = myTurn && you.coins > 0 + const avocados = you.avocados ?? 0 + const freeBuy = !!you.firstBuyFree // Manta Ray: next buy costs no gold + const canBuy = myTurn && (you.coins > 0 || avocados > 0 || freeBuy) const overPets = you.petCount > view.maxPets const deck = you.deck ?? [] const opponent = view.players.find((p) => p.seat !== view.youSeat) @@ -151,7 +156,11 @@ export function ShopPhase({ view, you, send }: Props) { // Safety net: if the card never shows up in the deck, stop hiding its slot. window.setTimeout(() => setBuyFly((b) => (b?.id === card.id ? null : b)), 2000) } - act({ type: 'buy', row }) + // A free first buy (Manta Ray) always goes through the normal buy; otherwise + // pay with an Avocado when chosen, or when out of coins. + const payAvocado = !freeBuy && avocados > 0 && (useAvocado || you.coins <= 0) + act({ type: payAvocado ? 'buyAvocado' : 'buy', row }) + setUseAvocado(false) } return ( @@ -212,13 +221,45 @@ export function ShopPhase({ view, you, send }: Props) { {myTurn && (
- {canBuy - ? 'Tap a card to buy it for 1 🪙 — selling and trading are free' - : 'No coins left — you can still sell, trade, or pass'} + {freeBuy + ? 'Your first buy this round is free 🎉 — tap a card' + : you.coins > 0 + ? 'Tap a card to buy it for 1 🪙 — selling and trading are free' + : avocados > 0 + ? 'No coins — tap a card to buy it by discarding an Avocado 🥑' + : 'No coins left — you can still sell, trade, or pass'}
)} + {/* Set-aside Avocados (Golden pack) */} + {avocados > 0 && ( +
+
+ Set aside + · discard instead of paying 1 🪙 +
+ + {myTurn && (useAvocado || you.coins <= 0) && ( +
Your next buy will discard an Avocado — no coin spent.
+ )} +
+ )} + {/* Your deck */}
@@ -350,6 +391,28 @@ export function ShopPhase({ view, you, send }: Props) {
)} + {/* Cockatoo reveal picker (Golden pack) */} + {view.pendingReveal?.playerId === you.id && ( +
+
+

Reveal a pet — gain Apples equal to its Power

+
+ {(view.pendingReveal.options ?? []).map((id) => { + const c = deck.find((d) => d.id === id) + return c ? ( + send({ type: 'revealChoose', card: id })} + /> + ) : null + })} +
+
+
+ )} + {cardFlyer && createPortal(
v {view.phase === 'shop' && ( 🪙 {p.coins} )} + {(p.avocados ?? 0) > 0 && ( + 🥑 {p.avocados} + )}
))} diff --git a/web/src/petArt.ts b/web/src/petArt.ts index 352228c..97c9582 100644 --- a/web/src/petArt.ts +++ b/web/src/petArt.ts @@ -11,6 +11,18 @@ const PET_EMOJI: Record = { Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃', // Tier 6 Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺', + // Golden pack — Tier 1 + Groundhog: '🦫', 'Pied Tamarin': '🐒', Chipmunk: '🐿️', 'Cone Snail': '🐚', Bulldog: '🐕', Opossum: '🐀', + // Golden pack — Tier 2 + 'Black-Necked Stilt': '🦤', Lizard: '🦎', 'Hercules Beetle': '🪲', Stoat: '🦡', 'Desert Rain Frog': '🐸', 'Honduran White Bat': '🦇', + // Golden pack — Tier 3 + 'Guinea Fowl': '🐔', 'Surgeon Fish': '🐠', Osprey: '🦅', Anteater: '🐜', Bear: '🐻', 'Royal Flycatcher': '🐦', Flea: '🦟', + // Golden pack — Tier 4 + 'Saiga Antelope': '🦌', Vaquita: '🐬', 'Poison Dart Frog': '🐸', 'Manta Ray': '🐟', Slug: '🐌', Cockatoo: '🦜', Manatee: '🦭', + // Golden pack — Tier 5 + 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': '🐕‍🦺', // Summons & foods Bee: '🐝', Apple: '🍎', @@ -19,6 +31,13 @@ const PET_EMOJI: Record = { Pineapple: '🍍', Chili: '🌶️', Melon: '🍈', + // Golden pack tokens & perk foods + 'Golden Retriever': '🦮', + Trumpet: '🎺', + Avocado: '🥑', + Potato: '🥔', + Durian: '🥭', + Tomato: '🍅', } export function artFor(name: string): string { diff --git a/web/src/styles.css b/web/src/styles.css index 263e49a..df6f35d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1224,6 +1224,56 @@ h3 { inset 0 0 0 1px rgba(246, 201, 78, 0.06); } +/* Mid-battle decision panel (Golden pack: Nurse Shark). */ +.battle-decision-options { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: center; + margin-top: 12px; +} + +.battle-decision-options .btn { + font-family: var(--font-display); +} + +/* Set-aside Avocado tokens (Golden pack): a slim tray with a toggle pill. */ +.avocado-zone { + background: rgba(0, 0, 0, 0.24); + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 18px; + padding: 12px 18px 14px; + box-shadow: + var(--tray-inset), + inset 0 0 0 1px rgba(246, 201, 78, 0.06); +} + +.avocado-token { + font-family: var(--font-display); + font-size: 1.05rem; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid rgba(246, 201, 78, 0.3); + background: rgba(0, 0, 0, 0.28); + color: var(--gold); + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease; +} + +.avocado-token:hover:not(:disabled) { + transform: translateY(-1px); +} + +.avocado-token.is-active { + border-color: var(--gold); + box-shadow: 0 0 10px rgba(246, 201, 78, 0.45); +} + +.avocado-token:disabled { + opacity: 0.6; + cursor: default; +} + .shop-row, .deck-row { display: flex; diff --git a/web/src/types.ts b/web/src/types.ts index b164712..53c0b2d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -29,6 +29,9 @@ export interface PlayerView { isBot?: boolean deckSize: number petCount: number + avocados?: number // set-aside Avocado tokens (Golden pack) + firstBuyFree?: boolean // Manta Ray: next buy is free (self only) + buysThisRound?: number // Blue-Ringed Octopus counter (self only) deck?: Card[] } @@ -38,6 +41,23 @@ export interface PendingTrade { options: [Card, Card] } +// Cockatoo (Golden pack): the buyer must reveal one of their pets. +export interface PendingReveal { + playerId: string + source: string + options?: string[] // eligible pet card ids (buyer only) +} + +// Nurse Shark (Golden pack): a mid-battle Trumpet-spend choice. +export interface PendingBattleDecision { + seat: number + kind: string + petName: string + min: number + max: number + trumpets: number +} + export interface BattleEvent { type: | 'prep' @@ -53,6 +73,8 @@ export interface BattleEvent { | 'heal' | 'setaside' | 'release' + | 'trumpet' // a side gains (+) or spends/loses (-) Trumpets (Golden pack) + | 'prevent' // a Cone Snail shaves damage off a hit (Golden pack) seat?: number target?: number card?: Card @@ -117,6 +139,8 @@ export interface GameView { deckCounts: number[] players: PlayerView[] pending?: PendingTrade + pendingReveal?: PendingReveal + pendingBattle?: PendingBattleDecision battle?: BattleResult winnerSeat: number log?: LogEntry[] @@ -129,9 +153,12 @@ export type ClientMessage = | { type: 'removePlayer'; target: string } | { type: 'start' } | { type: 'buy'; row: number } + | { type: 'buyAvocado'; row: number } | { type: 'sell'; cards: string[] } | { type: 'trade'; cards: string[] } | { type: 'tradeChoose'; pick: number } + | { type: 'revealChoose'; card: string } + | { type: 'battleChoose'; value: number } | { type: 'pass' } | { type: 'arrange'; order: string[] } | { type: 'ready' }