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

616 lines
16 KiB
Go

package game
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"math/big"
"slices"
)
// 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 spending coins
PhaseCleanup Phase = "cleanup" // forced discard down to MaxPets pets
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"` // 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"`
}
// 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
// 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
}
// 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
}
// 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 p.Coins <= 0 {
return nil, ErrNoCoins
}
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.
func (g *Game) Buy(playerID string, rowIdx int) error {
p, err := g.requireShopTurn(playerID)
if err != nil {
return err
}
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.ShopRow[rowIdx] = g.drawFromTier(g.Round)
g.applyShopTrigger(p, bought, TriggerBuy)
g.advanceShopTurn()
return nil
}
// Sell spends one coin to sell any number (>=1) of the player's cards: each
// becomes an apple, and Sell effects on the sold cards fire.
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
}
p.Coins--
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:
for range e.count() {
p.Deck = append(p.Deck, g.newApple())
}
case ActionRefreshGold:
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
case ActionDoubleApples:
apples := 0
for _, dc := range p.Deck {
if dc.Food == FoodApple {
apples++
}
}
for range apples {
p.Deck = append(p.Deck, g.newApple())
}
}
}
}
// 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.applyShopTrigger(p, c, TriggerSell)
}
return nil
}
// TradeStart spends one coin and three same-suit pets from the player's deck
// to reveal the top two cards of the next tier's deck. 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.Coins--
p.TripledThisRound = true
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
// Pets obtained via the Triple action trigger their Buy effects.
g.applyShopTrigger(p, chosen, TriggerBuy)
g.advanceShopTurn()
return nil
}
// Pass forfeits the player's remaining coins and ends their shopping.
func (g *Game) Pass(playerID string) error {
p, err := g.requireShopTurn(playerID)
if err != nil {
return err
}
p.Coins = 0
g.advanceShopTurn()
return nil
}
// advanceShopTurn hands the turn to the next player who still has coins, or
// moves the game onward when everyone is spent.
func (g *Game) advanceShopTurn() {
for i := 1; i <= len(g.Players); i++ {
seat := (g.Turn + i) % len(g.Players)
if g.Players[seat].Coins > 0 {
g.Turn = seat
return
}
}
g.endShop()
}
// endShop moves to forced discard if anyone is over the pet limit, otherwise
// straight to arranging.
func (g *Game) endShop() {
over := false
for _, p := range g.Players {
p.Ready = p.PetCount() <= MaxPets
if !p.Ready {
over = true
}
}
if over {
g.Phase = PhaseCleanup
return
}
g.beginArrange()
}
// CleanupSell performs the forced end-of-shop sale: the player must sell
// exactly their excess pets (each becomes an apple; Sell effects fire).
func (g *Game) CleanupSell(playerID string, cardIDs []string) error {
if g.Phase != PhaseCleanup {
return ErrWrongPhase
}
p := g.PlayerByID(playerID)
if p == nil {
return errors.New("unknown player")
}
excess := p.PetCount() - MaxPets
if excess <= 0 {
return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction)
}
if len(cardIDs) != excess {
return fmt.Errorf("%w: sell exactly %d pets", ErrInvalidAction, excess)
}
for _, id := range cardIDs {
idx := p.cardIndex(id)
if idx < 0 || !p.Deck[idx].IsPet() {
return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction)
}
}
if err := g.sellCards(p, cardIDs); err != nil {
return err
}
p.Ready = true
if g.allReady() {
g.beginArrange()
}
return nil
}
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
}