Add pets for tiers 1-3.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:17 -04:00
parent 612a4e6227
commit 8d43bbf61e
14 changed files with 1915 additions and 406 deletions
+71 -17
View File
@@ -85,6 +85,18 @@ type Game struct {
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 (
@@ -247,23 +259,25 @@ func (g *Game) Buy(playerID string, rowIdx int) error {
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
}
p.Coins--
p.Deck = append(p.Deck, g.ShopRow[rowIdx])
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
}
// Discard spends one coin to convert any number (>=1) of the player's cards
// into that many apples.
func (g *Game) Discard(playerID string, cardIDs []string) error {
// 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 discard", ErrInvalidAction)
return fmt.Errorf("%w: choose at least one card to sell", ErrInvalidAction)
}
if err := g.convertToApples(p, cardIDs); err != nil {
if err := g.sellCards(p, cardIDs); err != nil {
return err
}
p.Coins--
@@ -271,9 +285,27 @@ func (g *Game) Discard(playerID string, cardIDs []string) error {
return nil
}
// convertToApples removes the given cards from p's deck and adds one apple
// per removed card. It validates before mutating.
func (g *Game) convertToApples(p *Player, cardIDs []string) error {
// applyShopTrigger fires a shop-time trigger (buy/sell/triple) on one card.
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
for _, e := range c.Effects {
if e.Trigger != trigger || g.Round < e.MinRound {
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)
}
}
}
// 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)
}
@@ -282,11 +314,15 @@ func (g *Game) convertToApples(p *Player, cardIDs []string) error {
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
}
}
sold := make([]Card, 0, len(cardIDs))
for _, id := range cardIDs {
p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1)
idx := p.cardIndex(id)
sold = append(sold, p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
for range cardIDs {
for _, c := range sold {
p.Deck = append(p.Deck, g.newApple())
g.applyShopTrigger(p, c, TriggerSell)
}
return nil
}
@@ -326,8 +362,11 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction)
}
// Validated; commit.
traded := make([]Card, 0, TradeInCount)
for _, id := range cardIDs {
p.Deck = slices.Delete(p.Deck, p.cardIndex(id), p.cardIndex(id)+1)
idx := p.cardIndex(id)
traded = append(traded, p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
p.Coins--
g.Pending = &PendingTrade{
@@ -335,6 +374,10 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
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
}
@@ -353,6 +396,8 @@ func (g *Game) TradeChoose(playerID string, pick int) error {
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
}
@@ -398,9 +443,9 @@ func (g *Game) endShop() {
g.beginArrange()
}
// CleanupDiscard performs the forced end-of-shop discard: the player must
// convert exactly their excess pets into apples.
func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error {
// 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
}
@@ -413,7 +458,7 @@ func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error {
return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction)
}
if len(cardIDs) != excess {
return fmt.Errorf("%w: discard exactly %d pets", ErrInvalidAction, excess)
return fmt.Errorf("%w: sell exactly %d pets", ErrInvalidAction, excess)
}
for _, id := range cardIDs {
idx := p.cardIndex(id)
@@ -421,7 +466,7 @@ func (g *Game) CleanupDiscard(playerID string, cardIDs []string) error {
return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction)
}
}
if err := g.convertToApples(p, cardIDs); err != nil {
if err := g.sellCards(p, cardIDs); err != nil {
return err
}
p.Ready = true
@@ -444,6 +489,11 @@ 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)
}
}
}
@@ -494,6 +544,10 @@ func (g *Game) AcknowledgeBattle(playerID string) error {
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