Only buying costs gold; selling and trading (Triple) are free. Pass is now a final action, legal only at or under the pet limit: it forfeits remaining gold and ends that player's shopping for the round, with the shop closing once everyone has passed. That makes the separate cleanup phase unreachable (you sell down in-shop before passing), so it is removed. The client asks for confirmation before passing, and the bot knows buys are the only coin sink, when passing is legal, and that it must sell down before it can pass.
663 lines
20 KiB
Go
663 lines
20 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"`
|
|
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
|
|
}
|