727 lines
22 KiB
Go
727 lines
22 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"`
|
|
// 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"`
|
|
// 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"`
|
|
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(),
|
|
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
|
|
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
|
|
}
|