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"` // 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"` } // 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"` 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 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"` 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:"-"` } // rollRockDie rolls one rock die: 0, 1, or 2 with equal probability. func (g *Game) rollRockDie() int { if g.RollDie != nil { return g.RollDie() } return randInt(3) } 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(), Phase: PhaseLobby, WinnerSeat: -1, } g.buildShopDecks() for i := range g.ShopDecks { shuffle(g.ShopDecks[i]) } return g } // AddPlayer seats a new player during the lobby phase and returns them (with // their secret token). The game starts automatically once full. 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) if len(g.Players) == MaxPlayers { g.start() } 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 } // 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 for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false p.TripledThisRound = false } g.ShopRow = make([]Card, ShopRowSize) for i := range g.ShopRow { g.ShopRow[i] = g.drawFromTier(g.Round) } // 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) } 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. func (g *Game) Buy(playerID string, rowIdx int) error { p, err := g.requireShopTurn(playerID) if err != nil { return err } if p.Coins <= 0 { return ErrNoCoins } if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" { return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction) } p.Coins-- 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)}) 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 // 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() 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 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)}) } } } } // 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.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 } // 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]) 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 traded in %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 trade 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 trade pick hidden.", p.Name)}) } // Pets obtained via the Triple action trigger their Buy effects. g.applyShopTrigger(p, chosen, TriggerBuy) g.advanceShopTurn() 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.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 } 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 } } 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 }