Initial pass at unicorn pack.
This commit is contained in:
+187
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user