package game import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "math/big" "slices" "strings" ) // Tunable rules. The engine supports any player count >= 2; MinPlayers / // MaxPlayers gate when a lobby can start (2 for now, more later). const ( MaxRounds = 6 CoinsPerRound = 3 ShopRowSize = 4 MaxPets = 5 TradeInCount = 3 MinPlayers = 2 MaxPlayers = 2 ) // Phase is the game's top-level state. type Phase string const ( PhaseLobby Phase = "lobby" // waiting for players PhaseShop Phase = "shop" // players take turns acting until all pass PhaseArrange Phase = "arrange" // players order their decks for battle PhaseBattle Phase = "battle" // battle resolved; players review the log PhaseGameOver Phase = "gameover" // all rounds played ) // Player holds everything about one seat. All fields are exported so a Game // serializes to JSON for persistence. type Player struct { ID string `json:"id"` Token string `json:"token"` // secret; never sent in views Name string `json:"name"` Seat int `json:"seat"` Coins int `json:"coins"` Deck []Card `json:"deck"` Trophies int `json:"trophies"` Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged Connected bool `json:"connected"` // 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"` // Mana (Unicorn pack) is a persistent resource, added to the play area as a // counter. It is gained shop-time (Cuddle Toad, Thunderbird) and battle-time // (Alchemedes, Fur-Bearing Trout), spent to power abilities (see // Effect.CostMana), and — unlike Trumpets — kept between rounds. Mana int `json:"mana,omitempty"` // NextRoundApples (Unicorn pack: Skeleton Dog) is apples banked in battle // that land in the player's hand at the start of the next round, then reset. NextRoundApples int `json:"nextRoundApples,omitempty"` // ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used // Bigfoot's reveal (once per round); ShopPeek is the card they saw — a // snapshot of the shop deck's top, shown only in that player's own view. ShopPeekedRound int `json:"shopPeekedRound,omitempty"` ShopPeek *Card `json:"shopPeek,omitempty"` // IsBot marks a computer-controlled seat. The engine treats bots exactly // like humans; the server drives their actions. BotLevel is the bot's // skill in [0, 1]; BotMemory is the bot's private notebook, opaque to the // engine and persisted with the game so knowledge survives restarts. IsBot bool `json:"isBot,omitempty"` BotLevel float64 `json:"botLevel,omitempty"` BotMemory json.RawMessage `json:"botMemory,omitempty"` } // PetCount counts pet cards in the player's deck. func (p *Player) PetCount() int { n := 0 for _, c := range p.Deck { if c.IsPet() { n++ } } return n } func (p *Player) cardIndex(cardID string) int { return slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == cardID }) } // PendingTrade is an in-progress trade-in: the trading player has paid and // must now pick one of two revealed cards from the next tier's deck. type PendingTrade struct { PlayerID string `json:"playerId"` Tier int `json:"tier"` // 1-based tier the options came from 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 // Apples, when > 0, is a fixed apple reward (Unicorn pack: Quetzalcoatl gives // 3). When 0 the reward is the revealed pet's Power (Golden pack: Cockatoo). Apples int `json:"apples,omitempty"` } // PendingSacrifice is an in-progress Water of Youth buy (Unicorn pack): the // buyer must discard one of their pets; that pet and the Water of Youth food // are consumed to take the top card of the next tier's deck for free. Tier is // the tier the reward comes from. type PendingSacrifice struct { PlayerID string `json:"playerId"` Source string `json:"source"` // the Water of Youth card id (also discarded) Tier int `json:"tier"` // 1-based tier the reward is drawn from Options []string `json:"options"` // eligible pet card ids to sacrifice } // Game is the complete authoritative state. It is a pure state machine: no // goroutines, no clocks, no I/O. Callers are responsible for locking. type Game struct { ID string `json:"id"` Code string `json:"code"` // Pack is the selected card pack (see packs.go). Chosen in the lobby by // the host; determines which cards fill the shop decks. Pack string `json:"pack"` Phase Phase `json:"phase"` Round int `json:"round"` // 1-based Players []*Player `json:"players"` ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 ShopRow []Card `json:"shopRow"` // empty ID = empty slot // Discards (Unicorn pack) is the pile of real cards that came from the shop // decks and later left a player's deck (sold, traded, sacrificed), keyed by // tier. Chimera and Abomination draw from it. Temporary cards never enter. Discards map[int][]Card `json:"discards,omitempty"` Turn int `json:"turn"` // seat with the current shop turn // PrioritySeat holds the priority token: that seat shops first each round // and wins simultaneity races in battle. Assigned randomly at game start; // a battle winner hands it to the loser, a loser keeps it, a draw leaves // it put. PrioritySeat int `json:"prioritySeat"` Pending *PendingTrade `json:"pending,omitempty"` // 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"` // PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack); // it blocks other shop actions on that seat until resolved, like Pending. PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"` Battle *BattleResult `json:"battle,omitempty"` // most recent battle NextCardID int `json:"nextCardId"` WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie // Log is the running, human-readable event log shown across every phase. Log []LogEntry `json:"log,omitempty"` LogSeq int `json:"logSeq"` // last assigned entry sequence number // 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:"-"` } // battleDraw returns a random value in [0, n) for a battle's randomness — rock // dice and Komodo's apple shuffle alike. The RollDie test override applies to // rock dice (n == 3). func (g *Game) battleDraw(n int) int { switch { case n <= 0: return 0 case n == 3 && g.RollDie != nil: return g.RollDie() // test override applies to rock dice default: return randInt(n) } } // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. func (g *Game) rollRockDie() int { return g.battleDraw(3) } // battleShuffle does an in-place Fisher-Yates over a battle deck, routing its // randomness through battleDraw like every other in-battle draw (Komodo). func (g *Game) battleShuffle(s []Card) { for i := len(s) - 1; i > 0; i-- { j := g.battleDraw(i + 1) s[i], s[j] = s[j], s[i] } } var ( ErrNotYourTurn = errors.New("not your turn") ErrWrongPhase = errors.New("action not allowed in this phase") ErrNoCoins = errors.New("no coins remaining") ErrInvalidAction = errors.New("invalid action") ) func randomID(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) } func randomCode() string { const letters = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // no easily-confused chars code := make([]byte, 5) for i := range code { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) if err != nil { panic(err) } code[i] = letters[n.Int64()] } return string(code) } func randInt(n int) int { v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) if err != nil { panic(err) } return int(v.Int64()) } func shuffle[T any](s []T) { for i := len(s) - 1; i > 0; i-- { j := randInt(i + 1) s[i], s[j] = s[j], s[i] } } // New creates a game in the lobby phase with its shop decks built and // shuffled. All later "randomness" is just drawing from these decks, so the // state is fully deterministic (and serializable) after this point. func New() *Game { g := &Game{ ID: randomID(16), Code: randomCode(), Pack: DefaultPack, Phase: PhaseLobby, WinnerSeat: -1, } g.buildDecks() return g } // buildDecks (re)creates and shuffles the shop decks for the current pack. // Called on creation and whenever the pack changes, so ShopDecks always match // g.Pack and are ready the moment the game starts. func (g *Game) buildDecks() { g.buildShopDecks() for i := range g.ShopDecks { shuffle(g.ShopDecks[i]) } } // AddPlayer seats a new player during the lobby phase and returns them (with // their secret token). The host starts the game explicitly once the lobby is // ready (see StartGame). func (g *Game) AddPlayer(name string) (*Player, error) { if g.Phase != PhaseLobby { return nil, fmt.Errorf("%w: game already started", ErrWrongPhase) } if len(g.Players) >= MaxPlayers { return nil, errors.New("game is full") } if name == "" { name = fmt.Sprintf("Player %d", len(g.Players)+1) } p := &Player{ ID: randomID(8), Token: randomID(16), Name: name, Seat: len(g.Players), } g.Players = append(g.Players, p) return p, nil } // AddBot seats a computer-controlled player. Bots count as connected from // the start; the server is responsible for driving their actions. func (g *Game) AddBot(name string, level float64) (*Player, error) { p, err := g.AddPlayer(name) if err != nil { return nil, err } p.IsBot = true p.BotLevel = min(max(level, 0), 1) p.Connected = true return p, nil } // SetPack changes the game's card pack during the lobby and rebuilds the shop // decks to match. Only playable packs may be selected. func (g *Game) SetPack(packID string) error { if g.Phase != PhaseLobby { return fmt.Errorf("%w: game already started", ErrWrongPhase) } pack, ok := packByID(packID) if !ok { return fmt.Errorf("%w: unknown pack", ErrInvalidAction) } if !pack.Playable { return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction) } g.Pack = pack.ID g.buildDecks() return nil } // RemovePlayer drops a seat from the lobby. The host (seat 0) can't be // removed. Remaining players are re-seated so seats stay contiguous. func (g *Game) RemovePlayer(targetID string) error { if g.Phase != PhaseLobby { return fmt.Errorf("%w: game already started", ErrWrongPhase) } idx := slices.IndexFunc(g.Players, func(p *Player) bool { return p.ID == targetID }) if idx < 0 { return fmt.Errorf("%w: no such player", ErrInvalidAction) } if idx == 0 { return fmt.Errorf("%w: the host can't be removed", ErrInvalidAction) } g.Players = slices.Delete(g.Players, idx, idx+1) for i, p := range g.Players { p.Seat = i } return nil } // StartGame begins the match from the lobby once enough players are seated. // The shop decks are already built for g.Pack (see buildDecks); this just // validates and makes the transition. func (g *Game) StartGame() error { if g.Phase != PhaseLobby { return fmt.Errorf("%w: game already started", ErrWrongPhase) } if len(g.Players) < MinPlayers { return fmt.Errorf("%w: need at least %d players to start", ErrInvalidAction, MinPlayers) } if pack, ok := packByID(g.Pack); !ok || !pack.Playable { return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction) } g.start() return nil } // PlayerByID returns the player, or nil. func (g *Game) PlayerByID(id string) *Player { for _, p := range g.Players { if p.ID == id { return p } } return nil } func (g *Game) start() { g.Round = 1 // The priority token starts with a random seat. g.PrioritySeat = randInt(len(g.Players)) g.startShopRound() } // startShopRound resets coins, deals the shop row from this round's tier // deck, and rotates the starting player. func (g *Game) startShopRound() { g.Phase = PhaseShop g.Pending = nil g.PendingReveal = nil g.PendingSacrifice = nil for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false p.TripledThisRound = false p.FirstBuyFree = false p.BuysThisRound = 0 p.ShopPeek = nil // Unicorn pack: apples banked in battle (Skeleton Dog) arrive in hand now. for range p.NextRoundApples { p.Deck = append(p.Deck, g.newApple()) } if p.NextRoundApples > 0 { g.logf(p.Seat, "🍎", "%s starts the round with %d banked apple%s.", p.Name, p.NextRoundApples, plural(p.NextRoundApples)) p.NextRoundApples = 0 } } g.ShopRow = make([]Card, ShopRowSize) for i := range g.ShopRow { 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) } // drawFromTier pops the top card of the given tier's deck (1-based tier). // Returns a zero Card if the deck is empty. func (g *Game) drawFromTier(tier int) Card { deck := g.ShopDecks[tier-1] if len(deck) == 0 { return Card{} } top := deck[0] g.ShopDecks[tier-1] = deck[1:] return top } // requireShopTurn validates that playerID may act in the shop right now. func (g *Game) requireShopTurn(playerID string) (*Player, error) { if g.Phase != PhaseShop { return nil, ErrWrongPhase } p := g.PlayerByID(playerID) if p == nil { return nil, errors.New("unknown player") } if g.Players[g.Turn].ID != playerID { return nil, ErrNotYourTurn } 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) } if g.PendingSacrifice != nil { return nil, fmt.Errorf("%w: finish your choice 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 (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 } // 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 || g.PendingSacrifice != 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) } 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 %s %s %s.", p.Name, how, article(bought.Name), bought.Name)}) g.ShopRow[rowIdx] = g.drawFromTier(g.Round) g.applyShopTrigger(p, bought, TriggerBuy) } // Sell converts any number (>=1) of the player's cards: each becomes an // apple, and Sell effects on the sold cards fire. Selling is free. func (g *Game) Sell(playerID string, cardIDs []string) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if len(cardIDs) == 0 { return fmt.Errorf("%w: choose at least one card to sell", ErrInvalidAction) } if err := g.sellCards(p, cardIDs); err != nil { return err } g.advanceShopTurn() return nil } // applyShopTrigger fires a shop-time trigger (buy/sell/triple/battle prep) // on one card. Battle-time actions on the same trigger (Monkey's // applesInPlay) are ignored here and handled by the battle resolver. func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { for _, e := range c.Effects { if e.Trigger != trigger || g.Round < e.MinRound { continue } if e.Condition == ConditionTripled && !p.TripledThisRound { continue } 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 ActionRevealTierForApples: // Unicorn pack: Quetzalcoatl — reveal a hand pet at tier <= Cap for a // fixed number of apples. if g.Phase != PhaseShop { continue } maxTier := e.Cap var opts []string for _, dc := range p.Deck { if dc.IsPet() && dc.ID != c.ID && (maxTier <= 0 || dc.Tier <= maxTier) { opts = append(opts, dc.ID) } } if len(opts) > 0 { g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts, Apples: e.count()} } case ActionApplesInPlay: // Golden pack: apples-in-play banked when sold (Hercules Beetle) or // bought (Bird of Paradise). Monkey's battle-prep version is resolved // inside resolveBattle, so battlePrep is excluded here. if trigger == TriggerSell || trigger == TriggerBuy { p.PendingApplesInPlay += e.count() g.logf(p.Seat, "🍎", "%s — %s will start the next battle with %d apple%s in play.", c.Name, p.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 — %s will start the next battle with %d Trumpet%s.", c.Name, p.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) case ActionDoubleApples: apples := 0 for _, dc := range p.Deck { if dc.Food == FoodApple { apples++ } } for range apples { p.Deck = append(p.Deck, g.newApple()) } if apples > 0 { g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Source: c.ID, Spawn: "apple", Count: apples, Text: fmt.Sprintf("%s doubles %s's apples (+%d).", c.Name, p.Name, apples)}) } case ActionGainMana: // Unicorn pack: shop-time mana (Cuddle Toad, Thunderbird). Battle-time // mana is handled by the resolver; skip it here. if trigger == TriggerBuy || trigger == TriggerSell || trigger == TriggerTriple { p.Mana += e.count() g.addLog(LogEntry{Seat: p.Seat, Icon: "🔮", Kind: LogMana, Source: c.ID, Count: e.count(), Text: fmt.Sprintf("%s gains %d Mana (now %d).", c.Name, e.count(), p.Mana)}) } case ActionUpgradeNextTier: // Unicorn pack: Water of Youth — sacrifice a pet + this food to buy the // top of the next tier for free. Only a real shop buy opens the choice. if trigger != TriggerBuy || g.Phase != PhaseShop { continue } nextTier := g.Round + 1 if nextTier > MaxRounds || len(g.ShopDecks[nextTier-1]) == 0 { continue // no higher tier to upgrade into; the food is wasted } var opts []string for _, dc := range p.Deck { if dc.IsPet() { opts = append(opts, dc.ID) } } if len(opts) > 0 { g.PendingSacrifice = &PendingSacrifice{PlayerID: p.ID, Source: c.ID, Tier: nextTier, Options: opts} } } } } // sellCards removes the given cards from p's deck, adds one apple per // removed card, and fires the sold cards' Sell effects. It validates before // mutating. func (g *Game) sellCards(p *Player, cardIDs []string) error { if hasDuplicates(cardIDs) { return fmt.Errorf("%w: duplicate card", ErrInvalidAction) } for _, id := range cardIDs { if p.cardIndex(id) < 0 { return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) } } sold := make([]Card, 0, len(cardIDs)) for _, id := range cardIDs { idx := p.cardIndex(id) sold = append(sold, p.Deck[idx]) p.Deck = slices.Delete(p.Deck, idx, idx+1) } for _, c := range sold { p.Deck = append(p.Deck, g.newApple()) g.discardCard(c) g.addLog(LogEntry{Seat: p.Seat, Icon: "🍎", Kind: LogSell, Source: c.ID, CardName: c.Name, Spawn: "apple", Text: fmt.Sprintf("%s sold %s — it becomes an apple.", p.Name, c.Name)}) g.applyShopTrigger(p, c, TriggerSell) } return nil } // hasBuyEffect reports whether a card carries any buy-triggered ability, which // means acquiring it does something visible to everyone. func hasBuyEffect(c Card) bool { for _, e := range c.Effects { if e.Trigger == TriggerBuy { return true } } 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. func (g *Game) TradeStart(playerID string, cardIDs []string) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if g.Round >= MaxRounds { return fmt.Errorf("%w: no higher tier to trade into", ErrInvalidAction) } if len(cardIDs) != TradeInCount || hasDuplicates(cardIDs) { return fmt.Errorf("%w: trade in exactly %d cards", ErrInvalidAction, TradeInCount) } var suit Suit for i, id := range cardIDs { idx := p.cardIndex(id) if idx < 0 { return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) } c := p.Deck[idx] if !c.IsPet() { return fmt.Errorf("%w: only pets have suits", ErrInvalidAction) } if i == 0 { suit = c.Suit } else if c.Suit != suit { return fmt.Errorf("%w: cards must share a suit", ErrInvalidAction) } } nextTier := g.Round + 1 if len(g.ShopDecks[nextTier-1]) < 2 { return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction) } // Validated; commit. traded := make([]Card, 0, TradeInCount) for _, id := range cardIDs { idx := p.cardIndex(id) traded = append(traded, p.Deck[idx]) g.discardCard(p.Deck[idx]) p.Deck = slices.Delete(p.Deck, idx, idx+1) } p.TripledThisRound = true // The discarded trio is public — everyone sees what was given up — even // though the pet ultimately chosen stays secret (see TradeChoose). names := make([]string, len(traded)) ids := make([]string, len(traded)) for i, c := range traded { names[i] = c.Name ids[i] = c.ID } g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTrade, Cards: ids, Text: fmt.Sprintf("%s tripled %s (%s) for a tier %d pick.", p.Name, strings.Join(names, ", "), suit, nextTier)}) g.Pending = &PendingTrade{ PlayerID: playerID, Tier: nextTier, Options: [2]Card{g.drawFromTier(nextTier), g.drawFromTier(nextTier)}, } // Triple effects fire on the traded-in cards themselves (e.g. Fish). for _, c := range traded { g.applyShopTrigger(p, c, TriggerTriple) } return nil } // TradeChoose resolves a pending trade: pick (0 or 1) joins the player's // deck, the other goes to the bottom of its tier deck. func (g *Game) TradeChoose(playerID string, pick int) error { if g.Phase != PhaseShop || g.Pending == nil || g.Pending.PlayerID != playerID { return fmt.Errorf("%w: no trade waiting on you", ErrInvalidAction) } if pick != 0 && pick != 1 { return fmt.Errorf("%w: pick 0 or 1", ErrInvalidAction) } p := g.PlayerByID(playerID) chosen, other := g.Pending.Options[pick], g.Pending.Options[1-pick] p.Deck = append(p.Deck, chosen) tierIdx := g.Pending.Tier - 1 g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other) g.Pending = nil // The pick is secret — opponents never saw the two revealed options and // can't see the deck. But a pet with a Buy ability performs it publicly, so // we have to reveal that pet (its effect log names it anyway). if hasBuyEffect(chosen) { g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick, CardName: chosen.Name, Text: fmt.Sprintf("%s's Triple pick is %s %s — its buy ability triggers.", p.Name, article(chosen.Name), chosen.Name)}) } else { g.addLog(LogEntry{Seat: p.Seat, Icon: "🔄", Kind: LogTradePick, Text: fmt.Sprintf("%s keeps their Triple pick hidden.", p.Name)}) } // 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 if g.PendingReveal.Apples > 0 { n = g.PendingReveal.Apples // Quetzalcoatl: fixed reward } 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 } // PeekShopDeck resolves Bigfoot's optional reveal (Unicorn pack): on the // player's shop turn, once per round, they look at the top card of the current // tier's shop deck. It doesn't spend gold or end the turn — it only stores a // private snapshot served in that player's own view. func (g *Game) PeekShopDeck(playerID string) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if p.ShopPeekedRound == g.Round { return fmt.Errorf("%w: already peeked this round", ErrInvalidAction) } hasBigfoot := false for _, c := range p.Deck { for _, e := range c.Effects { if e.Action == ActionPeekShop { hasBigfoot = true } } } if !hasBigfoot { return fmt.Errorf("%w: no pet can peek the shop deck", ErrInvalidAction) } p.ShopPeekedRound = g.Round deck := g.ShopDecks[g.Round-1] if len(deck) > 0 { top := deck[0] p.ShopPeek = &top } g.logf(p.Seat, "👁", "%s reveals a pet to peek at the shop deck.", p.Name) return nil } // SacrificeChoose resolves a pending Water of Youth choice (Unicorn pack): the // chosen pet and the Water of Youth food are discarded, and the top of the next // tier's deck joins the player's deck for free (its Buy effect fires). func (g *Game) SacrificeChoose(playerID, cardID string) error { if g.Phase != PhaseShop || g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != playerID { return fmt.Errorf("%w: no choice waiting on you", ErrInvalidAction) } if !slices.Contains(g.PendingSacrifice.Options, cardID) { return fmt.Errorf("%w: choose one of your pets", ErrInvalidAction) } p := g.PlayerByID(playerID) ps := g.PendingSacrifice g.PendingSacrifice = nil // Discard the sacrificed pet and the Water of Youth food. var sacrificed Card if idx := p.cardIndex(cardID); idx >= 0 { sacrificed = p.Deck[idx] g.discardCard(sacrificed) p.Deck = slices.Delete(p.Deck, idx, idx+1) } if idx := p.cardIndex(ps.Source); idx >= 0 { g.discardCard(p.Deck[idx]) p.Deck = slices.Delete(p.Deck, idx, idx+1) } top := g.drawFromTier(ps.Tier) if top.ID == "" { // Tier emptied out between the buy and the choice; nothing to grant. g.logf(p.Seat, "⏳", "%s's Water of Youth fizzles — the next tier is empty.", p.Name) g.advanceShopTurn() return nil } p.Deck = append(p.Deck, top) if hasBuyEffect(top) { g.addLog(LogEntry{Seat: p.Seat, Icon: "⏳", Kind: LogBuy, Source: top.ID, CardName: top.Name, Text: fmt.Sprintf("%s trades %s for %s %s from tier %d — its buy ability triggers.", p.Name, sacrificed.Name, article(top.Name), top.Name, ps.Tier)}) } else { g.logf(p.Seat, "⏳", "%s trades %s for a fresh tier %d pet.", p.Name, sacrificed.Name, ps.Tier) } g.applyShopTrigger(p, top, TriggerBuy) g.advanceAfterBuy() return nil } // DebugGrant drops any card straight into a player's deck during the shop, // free and off-turn — a testing aid gated behind the server's DEBUG flag, not // a normal action. No buy effects fire. func (g *Game) DebugGrant(playerID, name string) error { if g.Phase != PhaseShop { return ErrWrongPhase } p := g.PlayerByID(playerID) if p == nil { return errors.New("unknown player") } c, ok := g.cardByName(name) if !ok { return fmt.Errorf("%w: no card named %q", ErrInvalidAction, name) } p.Deck = append(p.Deck, c) return nil } // Pass is a player's final shop action: it ends their shopping for the rest // of the round, forfeiting any remaining coins. It is only legal at or under // the pet limit — a player holding too many pets must sell down first. func (g *Game) Pass(playerID string) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if p.PetCount() > MaxPets { return fmt.Errorf("%w: sell down to %d pets before passing", ErrInvalidAction, MaxPets) } p.Coins = 0 p.Ready = true g.logf(p.Seat, "✋", "%s passed — done shopping this round.", p.Name) g.advanceShopTurn() return nil } // advanceShopTurn hands the turn to the next player still shopping, or moves // on to arranging once everyone has passed. Passing is the only way out of // the shop, and it requires being at the pet limit, so no cleanup step is // needed here. func (g *Game) advanceShopTurn() { for i := 1; i <= len(g.Players); i++ { seat := (g.Turn + i) % len(g.Players) if !g.Players[seat].Ready { g.Turn = seat return } } g.beginArrange() } func (g *Game) allReady() bool { for _, p := range g.Players { if !p.Ready { return false } } return true } func (g *Game) beginArrange() { g.Phase = PhaseArrange for _, p := range g.Players { p.Ready = false // Battle Prep effects fire now, before players order their cards // (e.g. Giraffe hands out apples that can go into the deck order). for _, c := range slices.Clone(p.Deck) { g.applyShopTrigger(p, c, TriggerBattlePrep) } } } // SubmitOrder records the player's battle ordering (a permutation of their // deck's card IDs, top of deck first). When everyone has submitted, the // battle resolves. func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error { if g.Phase != PhaseArrange { return ErrWrongPhase } p := g.PlayerByID(playerID) if p == nil { return errors.New("unknown player") } if p.Ready { return fmt.Errorf("%w: order already submitted", ErrInvalidAction) } if len(orderedIDs) != len(p.Deck) || hasDuplicates(orderedIDs) { return fmt.Errorf("%w: order must include each of your cards exactly once", ErrInvalidAction) } ordered := make([]Card, 0, len(p.Deck)) for _, id := range orderedIDs { idx := p.cardIndex(id) if idx < 0 { return fmt.Errorf("%w: card not in your deck", ErrInvalidAction) } ordered = append(ordered, p.Deck[idx]) } p.Deck = ordered p.Ready = true if g.allReady() { g.startBattle() } return nil } // startBattle enters the battle phase and resolves it. func (g *Game) startBattle() { for _, p := range g.Players { p.Ready = false } g.Phase = PhaseBattle g.resolveBattle() } // 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 } p := g.PlayerByID(playerID) if p == nil { return errors.New("unknown player") } p.Ready = true if !g.allReady() { return nil } // Temporary cards (apples, bees) expire once their battle has happened. for _, pl := range g.Players { pl.Deck = slices.DeleteFunc(pl.Deck, func(c Card) bool { return c.Temporary }) } if g.Round >= MaxRounds { g.finish() return nil } g.Round++ g.startShopRound() return nil } func (g *Game) finish() { g.Phase = PhaseGameOver best, bestSeat, tie := -1, -1, false for _, p := range g.Players { switch { case p.Trophies > best: best, bestSeat, tie = p.Trophies, p.Seat, false case p.Trophies == best: tie = true } } if tie { g.WinnerSeat = -1 } else { g.WinnerSeat = bestSeat } } // discardCard records a real (non-temporary) card leaving a player's deck into // the tier's discard pile (Unicorn pack: Chimera / Abomination source). Apples, // bees, and ailments never enter the pile. func (g *Game) discardCard(c Card) { if c.Temporary || c.Tier <= 0 { return } if g.Discards == nil { g.Discards = map[int][]Card{} } g.Discards[c.Tier] = append(g.Discards[c.Tier], c) } func hasDuplicates(ids []string) bool { seen := make(map[string]struct{}, len(ids)) for _, id := range ids { if _, ok := seen[id]; ok { return true } seen[id] = struct{}{} } return false }