970 lines
30 KiB
Go
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
|
|
}
|