Initial pass at unicorn pack.

This commit is contained in:
Greyson Parrelli
2026-07-25 00:37:44 -04:00
parent f3783b44bf
commit a1d57fe1dd
21 changed files with 2419 additions and 44 deletions
+187 -2
View File
@@ -66,6 +66,19 @@ type Player struct {
// 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"`
// Mana (Unicorn pack) is a persistent resource, added to the play area as a
// counter. It is gained shop-time (Cuddle Toad, Thunderbird) and battle-time
// (Alchemedes, Fur-Bearing Trout), spent to power abilities (see
// Effect.CostMana), and — unlike Trumpets — kept between rounds.
Mana int `json:"mana,omitempty"`
// NextRoundApples (Unicorn pack: Skeleton Dog) is apples banked in battle
// that land in the player's hand at the start of the next round, then reset.
NextRoundApples int `json:"nextRoundApples,omitempty"`
// ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used
// Bigfoot's reveal (once per round); ShopPeek is the card they saw — a
// snapshot of the shop deck's top, shown only in that player's own view.
ShopPeekedRound int `json:"shopPeekedRound,omitempty"`
ShopPeek *Card `json:"shopPeek,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
@@ -105,6 +118,20 @@ 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
// Apples, when > 0, is a fixed apple reward (Unicorn pack: Quetzalcoatl gives
// 3). When 0 the reward is the revealed pet's Power (Golden pack: Cockatoo).
Apples int `json:"apples,omitempty"`
}
// PendingSacrifice is an in-progress Water of Youth buy (Unicorn pack): the
// buyer must discard one of their pets; that pet and the Water of Youth food
// are consumed to take the top card of the next tier's deck for free. Tier is
// the tier the reward comes from.
type PendingSacrifice struct {
PlayerID string `json:"playerId"`
Source string `json:"source"` // the Water of Youth card id (also discarded)
Tier int `json:"tier"` // 1-based tier the reward is drawn from
Options []string `json:"options"` // eligible pet card ids to sacrifice
}
// Game is the complete authoritative state. It is a pure state machine: no
@@ -120,6 +147,10 @@ type Game struct {
Players []*Player `json:"players"`
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
ShopRow []Card `json:"shopRow"` // empty ID = empty slot
// Discards (Unicorn pack) is the pile of real cards that came from the shop
// decks and later left a player's deck (sold, traded, sacrificed), keyed by
// tier. Chimera and Abomination draw from it. Temporary cards never enter.
Discards map[int][]Card `json:"discards,omitempty"`
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;
@@ -130,7 +161,10 @@ type Game struct {
// 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
// PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack);
// it blocks other shop actions on that seat until resolved, like Pending.
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,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.
@@ -350,12 +384,22 @@ func (g *Game) startShopRound() {
g.Phase = PhaseShop
g.Pending = nil
g.PendingReveal = nil
g.PendingSacrifice = nil
for _, p := range g.Players {
p.Coins = CoinsPerRound
p.Ready = false
p.TripledThisRound = false
p.FirstBuyFree = false
p.BuysThisRound = 0
p.ShopPeek = nil
// Unicorn pack: apples banked in battle (Skeleton Dog) arrive in hand now.
for range p.NextRoundApples {
p.Deck = append(p.Deck, g.newApple())
}
if p.NextRoundApples > 0 {
g.logf(p.Seat, "🍎", "%s starts the round with %d banked apple%s.", p.Name, p.NextRoundApples, plural(p.NextRoundApples))
p.NextRoundApples = 0
}
}
g.ShopRow = make([]Card, ShopRowSize)
for i := range g.ShopRow {
@@ -402,6 +446,9 @@ func (g *Game) requireShopTurn(playerID string) (*Player, error) {
if g.PendingReveal != nil {
return nil, fmt.Errorf("%w: finish your reveal first", ErrInvalidAction)
}
if g.PendingSacrifice != nil {
return nil, fmt.Errorf("%w: finish your choice first", ErrInvalidAction)
}
return p, nil
}
@@ -459,7 +506,7 @@ func (g *Game) BuyAvocado(playerID string, rowIdx int) error {
// 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 {
if g.PendingReveal != nil || g.PendingSacrifice != nil {
return
}
g.advanceShopTurn()
@@ -552,6 +599,22 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
if len(opts) > 0 {
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts}
}
case ActionRevealTierForApples:
// Unicorn pack: Quetzalcoatl — reveal a hand pet at tier <= Cap for a
// fixed number of apples.
if g.Phase != PhaseShop {
continue
}
maxTier := e.Cap
var opts []string
for _, dc := range p.Deck {
if dc.IsPet() && dc.ID != c.ID && (maxTier <= 0 || dc.Tier <= maxTier) {
opts = append(opts, dc.ID)
}
}
if len(opts) > 0 {
g.PendingReveal = &PendingReveal{PlayerID: p.ID, Source: c.ID, Options: opts, Apples: e.count()}
}
case ActionApplesInPlay:
// Golden pack: apples-in-play banked when sold (Hercules Beetle) or
// bought (Bird of Paradise). Monkey's battle-prep version is resolved
@@ -612,6 +675,33 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
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)})
}
case ActionGainMana:
// Unicorn pack: shop-time mana (Cuddle Toad, Thunderbird). Battle-time
// mana is handled by the resolver; skip it here.
if trigger == TriggerBuy || trigger == TriggerSell || trigger == TriggerTriple {
p.Mana += e.count()
g.addLog(LogEntry{Seat: p.Seat, Icon: "🔮", Kind: LogMana, Source: c.ID, Count: e.count(),
Text: fmt.Sprintf("%s gains %d Mana (now %d).", c.Name, e.count(), p.Mana)})
}
case ActionUpgradeNextTier:
// Unicorn pack: Water of Youth — sacrifice a pet + this food to buy the
// top of the next tier for free. Only a real shop buy opens the choice.
if trigger != TriggerBuy || g.Phase != PhaseShop {
continue
}
nextTier := g.Round + 1
if nextTier > MaxRounds || len(g.ShopDecks[nextTier-1]) == 0 {
continue // no higher tier to upgrade into; the food is wasted
}
var opts []string
for _, dc := range p.Deck {
if dc.IsPet() {
opts = append(opts, dc.ID)
}
}
if len(opts) > 0 {
g.PendingSacrifice = &PendingSacrifice{PlayerID: p.ID, Source: c.ID, Tier: nextTier, Options: opts}
}
}
}
}
@@ -636,6 +726,7 @@ func (g *Game) sellCards(p *Player, cardIDs []string) error {
}
for _, c := range sold {
p.Deck = append(p.Deck, g.newApple())
g.discardCard(c)
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)
@@ -711,6 +802,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
for _, id := range cardIDs {
idx := p.cardIndex(id)
traded = append(traded, p.Deck[idx])
g.discardCard(p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
p.TripledThisRound = true
@@ -785,6 +877,9 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
}
revealed := p.Deck[idx]
n := revealed.Power
if g.PendingReveal.Apples > 0 {
n = g.PendingReveal.Apples // Quetzalcoatl: fixed reward
}
for range n {
p.Deck = append(p.Deck, g.newApple())
}
@@ -797,6 +892,83 @@ func (g *Game) RevealChoose(playerID, cardID string) error {
return nil
}
// PeekShopDeck resolves Bigfoot's optional reveal (Unicorn pack): on the
// player's shop turn, once per round, they look at the top card of the current
// tier's shop deck. It doesn't spend gold or end the turn — it only stores a
// private snapshot served in that player's own view.
func (g *Game) PeekShopDeck(playerID string) error {
p, err := g.requireShopTurn(playerID)
if err != nil {
return err
}
if p.ShopPeekedRound == g.Round {
return fmt.Errorf("%w: already peeked this round", ErrInvalidAction)
}
hasBigfoot := false
for _, c := range p.Deck {
for _, e := range c.Effects {
if e.Action == ActionPeekShop {
hasBigfoot = true
}
}
}
if !hasBigfoot {
return fmt.Errorf("%w: no pet can peek the shop deck", ErrInvalidAction)
}
p.ShopPeekedRound = g.Round
deck := g.ShopDecks[g.Round-1]
if len(deck) > 0 {
top := deck[0]
p.ShopPeek = &top
}
g.logf(p.Seat, "👁", "%s reveals a pet to peek at the shop deck.", p.Name)
return nil
}
// SacrificeChoose resolves a pending Water of Youth choice (Unicorn pack): the
// chosen pet and the Water of Youth food are discarded, and the top of the next
// tier's deck joins the player's deck for free (its Buy effect fires).
func (g *Game) SacrificeChoose(playerID, cardID string) error {
if g.Phase != PhaseShop || g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != playerID {
return fmt.Errorf("%w: no choice waiting on you", ErrInvalidAction)
}
if !slices.Contains(g.PendingSacrifice.Options, cardID) {
return fmt.Errorf("%w: choose one of your pets", ErrInvalidAction)
}
p := g.PlayerByID(playerID)
ps := g.PendingSacrifice
g.PendingSacrifice = nil
// Discard the sacrificed pet and the Water of Youth food.
var sacrificed Card
if idx := p.cardIndex(cardID); idx >= 0 {
sacrificed = p.Deck[idx]
g.discardCard(sacrificed)
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
if idx := p.cardIndex(ps.Source); idx >= 0 {
g.discardCard(p.Deck[idx])
p.Deck = slices.Delete(p.Deck, idx, idx+1)
}
top := g.drawFromTier(ps.Tier)
if top.ID == "" {
// Tier emptied out between the buy and the choice; nothing to grant.
g.logf(p.Seat, "⏳", "%s's Water of Youth fizzles — the next tier is empty.", p.Name)
g.advanceShopTurn()
return nil
}
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 trades %s for %s %s from tier %d — its buy ability triggers.",
p.Name, sacrificed.Name, article(top.Name), top.Name, ps.Tier)})
} else {
g.logf(p.Seat, "⏳", "%s trades %s for a fresh tier %d pet.", p.Name, sacrificed.Name, ps.Tier)
}
g.applyShopTrigger(p, top, TriggerBuy)
g.advanceAfterBuy()
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.
@@ -957,6 +1129,19 @@ func (g *Game) finish() {
}
}
// discardCard records a real (non-temporary) card leaving a player's deck into
// the tier's discard pile (Unicorn pack: Chimera / Abomination source). Apples,
// bees, and ailments never enter the pile.
func (g *Game) discardCard(c Card) {
if c.Temporary || c.Tier <= 0 {
return
}
if g.Discards == nil {
g.Discards = map[int][]Card{}
}
g.Discards[c.Tier] = append(g.Discards[c.Tier], c)
}
func hasDuplicates(ids []string) bool {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {