Files
super-auto-pets-board-game/internal/game/game.go
T

970 lines
30 KiB
Go

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"`
// 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
}
// 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
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"`
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
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)
}
// 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)
}
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 {
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 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)})
}
}
}
}
// 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
}
// 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])
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
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
}
// 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
}
}
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
}