Initial pass at unicorn pack.
This commit is contained in:
+31
-2
@@ -35,12 +35,12 @@ import (
|
||||
|
||||
// Action is one move the bot wants to make, mirroring the client protocol.
|
||||
type Action struct {
|
||||
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose
|
||||
Type string // buy | buyAvocado | sell | trade | tradeChoose | pass | arrange | ready | revealChoose | sacrificeChoose
|
||||
Row int // buy / buyAvocado
|
||||
Cards []string // sell / trade
|
||||
Pick int // tradeChoose
|
||||
Order []string // arrange
|
||||
CardID string // revealChoose (Cockatoo): the pet to reveal
|
||||
CardID string // revealChoose (Cockatoo) / sacrificeChoose (Water of Youth): the pet
|
||||
}
|
||||
|
||||
// Bot is a computer player at a fixed difficulty level.
|
||||
@@ -62,6 +62,12 @@ func (b *Bot) Act(v *game.View, mem *Memory) *Action {
|
||||
me := &v.Players[v.YouSeat]
|
||||
switch v.Phase {
|
||||
case game.PhaseShop:
|
||||
if v.PendingSacrifice != nil {
|
||||
if v.PendingSacrifice.PlayerID == me.ID {
|
||||
return b.decideSacrifice(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if v.PendingReveal != nil {
|
||||
if v.PendingReveal.PlayerID == me.ID {
|
||||
return b.decideReveal(v)
|
||||
@@ -107,6 +113,26 @@ func (b *Bot) decideReveal(v *game.View) *Action {
|
||||
return &Action{Type: "revealChoose", CardID: best}
|
||||
}
|
||||
|
||||
// decideSacrifice resolves Water of Youth (Unicorn pack): give up the
|
||||
// lowest-value eligible pet to upgrade into a next-tier card.
|
||||
func (b *Bot) decideSacrifice(v *game.View) *Action {
|
||||
me := &v.Players[v.YouSeat]
|
||||
worst, worstVal := "", math.Inf(1)
|
||||
for _, id := range v.PendingSacrifice.Options {
|
||||
for _, c := range me.Deck {
|
||||
if c.ID == id {
|
||||
if val := keepValue(c); val < worstVal {
|
||||
worst, worstVal = id, val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst == "" && len(v.PendingSacrifice.Options) > 0 {
|
||||
worst = v.PendingSacrifice.Options[0]
|
||||
}
|
||||
return &Action{Type: "sacrificeChoose", CardID: worst}
|
||||
}
|
||||
|
||||
// Pending reports whether the seat owes the game an action right now — the
|
||||
// server uses it to decide when to schedule a bot move.
|
||||
func Pending(v *game.View) bool {
|
||||
@@ -116,6 +142,9 @@ func Pending(v *game.View) bool {
|
||||
me := &v.Players[v.YouSeat]
|
||||
switch v.Phase {
|
||||
case game.PhaseShop:
|
||||
if v.PendingSacrifice != nil {
|
||||
return v.PendingSacrifice.PlayerID == me.ID
|
||||
}
|
||||
if v.PendingReveal != nil {
|
||||
return v.PendingReveal.PlayerID == me.ID
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ func applyAction(g *game.Game, playerID string, a *Action) error {
|
||||
return g.TradeChoose(playerID, a.Pick)
|
||||
case "revealChoose":
|
||||
return g.RevealChoose(playerID, a.CardID)
|
||||
case "sacrificeChoose":
|
||||
return g.SacrificeChoose(playerID, a.CardID)
|
||||
case "pass":
|
||||
return g.Pass(playerID)
|
||||
case "arrange":
|
||||
@@ -139,6 +141,20 @@ func TestBotsFinishGoldenGame(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsFinishUnicornGame plays complete games on the Unicorn pack (tiers
|
||||
// 1-3 printed; 4-6 still in progress, so late shops are barren but battles
|
||||
// still resolve). It exercises the Mana and Ailment mechanics via
|
||||
// SimulateBattle rollouts plus the new shop effects (Water of Youth's sacrifice
|
||||
// choice, Bigfoot), and fails on any illegal or missing bot action.
|
||||
func TestBotsFinishUnicornGame(t *testing.T) {
|
||||
for range 5 {
|
||||
g := playBotGamePack(t, "unicorn", 1, 0.6)
|
||||
if g.Round != game.MaxRounds {
|
||||
t.Errorf("unicorn game ended on round %d, want %d", g.Round, game.MaxRounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveTracksOpponentDeck checks the memory's opponent model against
|
||||
// the opponent's real deck after known public actions. The model may only
|
||||
// contain information a human spectator would have.
|
||||
|
||||
@@ -52,6 +52,20 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
||||
for range best {
|
||||
deck = append(deck, cx.simApple())
|
||||
}
|
||||
case game.ActionRevealTierForApples:
|
||||
// Quetzalcoatl: a fixed apple reward if a low-tier pet can be revealed.
|
||||
eligible := false
|
||||
for _, d := range deck {
|
||||
if d.IsPet() && d.ID != c.ID && (e.Cap <= 0 || d.Tier <= e.Cap) {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if eligible {
|
||||
for range max(e.Count, 1) {
|
||||
deck = append(deck, cx.simApple())
|
||||
}
|
||||
}
|
||||
case game.ActionDoubleApples:
|
||||
apples := 0
|
||||
for _, dc := range deck {
|
||||
@@ -87,6 +101,39 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
||||
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
|
||||
deck = slices.Delete(deck, i, i+1)
|
||||
}
|
||||
case game.ActionUpgradeNextTier:
|
||||
// Unicorn pack (Water of Youth): discard this food and the lowest-value
|
||||
// pet, then gain a sampled top card of the next tier for free.
|
||||
nextTier := cx.v.Round + 1
|
||||
if nextTier > game.MaxRounds {
|
||||
continue
|
||||
}
|
||||
if i := slices.IndexFunc(deck, func(d game.Card) bool { return d.ID == c.ID }); i >= 0 {
|
||||
deck = slices.Delete(deck, i, i+1)
|
||||
}
|
||||
worst, worstVal := -1, 0.0
|
||||
for i, d := range deck {
|
||||
if !d.IsPet() {
|
||||
continue
|
||||
}
|
||||
if v := keepValue(d); worst < 0 || v < worstVal {
|
||||
worst, worstVal = i, v
|
||||
}
|
||||
}
|
||||
if worst < 0 {
|
||||
continue // no pet to sacrifice; the food is wasted
|
||||
}
|
||||
deck = slices.Delete(deck, worst, worst+1)
|
||||
pool := cx.unseenPool(nextTier)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(cx.v.Pack, nextTier)
|
||||
}
|
||||
if len(pool) > 0 {
|
||||
rc := pool[rand.IntN(len(pool))]
|
||||
rc.ID = cx.nextSimID()
|
||||
deck = append(deck, rc)
|
||||
deck = cx.applyTemplateShopEffects(deck, rc, game.TriggerBuy)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deck
|
||||
|
||||
+486
-23
@@ -20,6 +20,12 @@ type BattleUnit struct {
|
||||
// hitPrevent are this pet's own one-shot partial damage preventions
|
||||
// (Potato perk: two charges of 2). Consumed before any side-level charge.
|
||||
hitPrevent []int
|
||||
// Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the
|
||||
// damage it deals in a clash (min 0); Exposed raises the damage it takes on
|
||||
// each hit. bakuGuard, when set, discards the first Ailment it would gain.
|
||||
Spooked int `json:"spooked,omitempty"`
|
||||
Exposed int `json:"exposed,omitempty"`
|
||||
bakuGuard bool
|
||||
}
|
||||
|
||||
// shieldCharge is one full-hit block a pet carries. source is the granting
|
||||
@@ -51,6 +57,25 @@ type preventInfo struct {
|
||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
||||
|
||||
// hasAilment reports whether the pet carries any Ailment (Mothman's condition).
|
||||
func (u *BattleUnit) hasAilment() bool { return u.Spooked > 0 || u.Exposed > 0 }
|
||||
|
||||
// takeAilment attaches one Ailment of the given kind, unless Baku's guard eats
|
||||
// it first. It reports whether the ailment actually landed.
|
||||
func (u *BattleUnit) takeAilment(kind string) bool {
|
||||
if u.bakuGuard {
|
||||
u.bakuGuard = false
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case AilmentSpooked:
|
||||
u.Spooked++
|
||||
case AilmentExposed:
|
||||
u.Exposed++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// activePerk returns the perk this unit benefits from: only the
|
||||
// last-applied perk counts when several are attached.
|
||||
func (u *BattleUnit) activePerk() *Card {
|
||||
@@ -124,6 +149,11 @@ type BattleEvent struct {
|
||||
// Turkey, Mammoth).
|
||||
// "release": a set-aside Card left Seat's zone, its effect consumed
|
||||
// (matched by Card.ID).
|
||||
// "mana": Seat's Mana pool changed by Count (+gain / −spend) (Unicorn).
|
||||
// "ailment": Seat's pet gained Count Ailment cards (Card names the kind)
|
||||
// (Unicorn: Barghest, Basilisk, Frost Wolf, …).
|
||||
// "bounce": Seat's pet sent Target's pet (Card) to the bottom of Target's
|
||||
// deck (Unicorn: Loveland Frogman).
|
||||
Type string `json:"type"`
|
||||
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
|
||||
// player) and dropping it makes the client read sides[undefined].
|
||||
@@ -165,6 +195,11 @@ type BattleResult struct {
|
||||
// decisive the result was — the margin the AI uses to prefer a lineup that
|
||||
// fights harder, even in a battle it can't win.
|
||||
Survivors []int `json:"survivors,omitempty"`
|
||||
// ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the
|
||||
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
|
||||
// is apples each seat banked for next round's hand (Skeleton Dog).
|
||||
ManaAfter []int `json:"manaAfter,omitempty"`
|
||||
NextRoundApples []int `json:"nextRoundApples,omitempty"`
|
||||
}
|
||||
|
||||
// setAsideRocks is a fainted pet's pending rock payout.
|
||||
@@ -214,6 +249,19 @@ type battleSide struct {
|
||||
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
|
||||
petsPlayed int // pets fielded so far (Komodo's "first pet")
|
||||
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits
|
||||
|
||||
// --- Unicorn pack ---
|
||||
mana int // persistent Mana pool, seeded from the player and written back
|
||||
pendingAilments []Card // ailments on top of the deck, waiting for the next pet
|
||||
negators []Card // Mandrake set-asides: each negates the next enemy Faint ability
|
||||
nextRoundApples int // apples banked for next round's hand (Skeleton Dog)
|
||||
|
||||
// --- Unicorn pack, tiers 4-6 ---
|
||||
unicornGuards []Card // Unicorn: each turns the next incoming friendly ailment into 2 apples
|
||||
smallPetBonus int // Rootlin: +power for friendly pets with base Power <= 2
|
||||
fairyGuards []Card // Fairy: each recycles the next friendly faint to the deck bottom
|
||||
ailmentBoost int // Manticore: enemy pets' Ailments count for this much more
|
||||
manaFeed []feedAside // Team Spirit: each pet played, spend 1 Mana to feed apples
|
||||
}
|
||||
|
||||
// hasPetInStack reports whether any pet remains face-down in the stack.
|
||||
@@ -324,6 +372,14 @@ func (g *Game) finalizeBattle(res *BattleResult) {
|
||||
for _, p := range g.Players {
|
||||
p.PendingApplesInPlay = 0
|
||||
p.PendingTrumpets = 0
|
||||
// Unicorn pack: persist the Mana pool as it stood at battle's end, and
|
||||
// bank any apples destined for next round's hand (Skeleton Dog).
|
||||
if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
|
||||
p.Mana = res.ManaAfter[p.Seat]
|
||||
}
|
||||
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
|
||||
p.NextRoundApples += res.NextRoundApples[p.Seat]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +395,9 @@ func (g *Game) runBattle() *BattleResult {
|
||||
|
||||
for _, p := range g.Players {
|
||||
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||||
// Unicorn pack: the persistent Mana pool comes into battle (read-only
|
||||
// here; written back by finalizeBattle so re-runs stay deterministic).
|
||||
s.mana = p.Mana
|
||||
sides[p.Seat] = s
|
||||
res.StackSizes[p.Seat] = len(p.Deck)
|
||||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||||
@@ -418,21 +477,104 @@ func (g *Game) runBattle() *BattleResult {
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
|
||||
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))})
|
||||
}
|
||||
// spend pays an effect's Trumpet cost from a side's pool, narrating the
|
||||
// spend. Returns false (without paying) when the side can't afford it, so
|
||||
// the caller skips the effect. A zero-cost effect always "pays".
|
||||
spend := func(seat int, e Effect, cause string) bool {
|
||||
if e.CostTrumpet <= 0 {
|
||||
return true
|
||||
// gainMana adds Mana to a side's persistent pool and narrates it.
|
||||
gainMana := func(seat, n int, cause string) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if sides[seat].trumpets < e.CostTrumpet {
|
||||
sides[seat].mana += n
|
||||
emit(BattleEvent{Type: "mana", Seat: seat, Count: n,
|
||||
Text: fmt.Sprintf("%s gains %d Mana (now %d).", cause, n, sides[seat].mana)})
|
||||
}
|
||||
// spend pays an effect's Trumpet and Mana costs from a side's pools,
|
||||
// narrating each. Returns false (paying nothing) when the side can't afford
|
||||
// the full cost, so the caller skips the effect. A cost-free effect always
|
||||
// "pays".
|
||||
spend := func(seat int, e Effect, cause string) bool {
|
||||
s := sides[seat]
|
||||
if s.trumpets < e.CostTrumpet || s.mana < e.CostMana {
|
||||
return false
|
||||
}
|
||||
sides[seat].trumpets -= e.CostTrumpet
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
|
||||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
|
||||
if e.CostTrumpet > 0 {
|
||||
s.trumpets -= e.CostTrumpet
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
|
||||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
|
||||
}
|
||||
if e.CostMana > 0 {
|
||||
s.mana -= e.CostMana
|
||||
emit(BattleEvent{Type: "mana", Seat: seat, Count: -e.CostMana,
|
||||
Text: fmt.Sprintf("%s spends %d Mana.", cause, e.CostMana)})
|
||||
}
|
||||
return true
|
||||
}
|
||||
// unicornConvert consumes one of side `s`'s Unicorn guards, if any, turning
|
||||
// an incoming ailment into 2 Apples on top of that side's deck. Returns true
|
||||
// when a guard fired (so the caller skips applying the ailment).
|
||||
unicornConvert := func(seat int) bool {
|
||||
s := sides[seat]
|
||||
if len(s.unicornGuards) == 0 {
|
||||
return false
|
||||
}
|
||||
card := s.unicornGuards[len(s.unicornGuards)-1]
|
||||
s.unicornGuards = s.unicornGuards[:len(s.unicornGuards)-1]
|
||||
emit(BattleEvent{Type: "release", Seat: seat, Card: &card,
|
||||
Text: fmt.Sprintf("%s's Unicorn turns an Ailment into 2 Apples.", pname(seat))})
|
||||
for range 2 {
|
||||
apple := g.newApple()
|
||||
s.stack = append([]Card{apple}, s.stack...)
|
||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &apple,
|
||||
Text: fmt.Sprintf("%s adds an apple on top of %s's deck.", pname(seat), pname(seat))})
|
||||
}
|
||||
return true
|
||||
}
|
||||
// addAilment (Unicorn pack) afflicts the enemy of `from`. toDeck drops the
|
||||
// ailment cards on top of the enemy deck (they snare the next pet revealed);
|
||||
// otherwise they attach to the enemy pet in play now. A friendly Unicorn
|
||||
// guard converts each incoming ailment to 2 apples; Baku eats one on a pet.
|
||||
addAilment := func(from int, kind string, count int, toDeck bool, cause string) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
target := (from + 1) % n
|
||||
ts := sides[target]
|
||||
if toDeck {
|
||||
for range count {
|
||||
if unicornConvert(target) {
|
||||
continue
|
||||
}
|
||||
a := g.newAilment(kind)
|
||||
ts.stack = append([]Card{a}, ts.stack...)
|
||||
emit(BattleEvent{Type: "summon", Seat: target, Card: &a,
|
||||
Text: fmt.Sprintf("%s adds %s on top of %s's deck.", cause, a.Name, pname(target))})
|
||||
}
|
||||
return
|
||||
}
|
||||
if ts.unit == nil {
|
||||
return
|
||||
}
|
||||
rep := g.newAilment(kind)
|
||||
landed, guarded := 0, 0
|
||||
for range count {
|
||||
if unicornConvert(target) {
|
||||
continue
|
||||
}
|
||||
if ts.unit.takeAilment(kind) {
|
||||
landed++
|
||||
} else {
|
||||
guarded++
|
||||
}
|
||||
}
|
||||
if landed == 0 && guarded == 0 {
|
||||
return // every ailment was converted to apples; nothing to narrate here
|
||||
}
|
||||
txt := fmt.Sprintf("%s afflicts %s with %d %s.", cause, ts.unit.Card.Name, landed, rep.Name)
|
||||
if landed == 0 {
|
||||
txt = fmt.Sprintf("%s shrugs off the %s.", ts.unit.Card.Name, rep.Name)
|
||||
} else if guarded > 0 {
|
||||
txt = fmt.Sprintf("%s afflicts %s with %d %s (one shrugged off).", cause, ts.unit.Card.Name, landed, rep.Name)
|
||||
}
|
||||
emit(BattleEvent{Type: "ailment", Seat: target, Card: &rep, Count: landed, Text: txt})
|
||||
}
|
||||
|
||||
// allowed gates battle-time effects on their conditions.
|
||||
allowed := func(e Effect, u *BattleUnit) bool {
|
||||
@@ -444,10 +586,22 @@ func (g *Game) runBattle() *BattleResult {
|
||||
return u != nil && u.activePerk() != nil
|
||||
case ConditionTripled:
|
||||
return false // shop-time condition; never true in battle
|
||||
case ConditionEvenRound:
|
||||
return g.Round%2 == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// effAilment returns a seat's pet's effective Spooked/Exposed values,
|
||||
// including any boost from an enemy Manticore set aside on the other side.
|
||||
// A pet with none of that ailment gets no boost. (Unicorn pack.)
|
||||
effAilment := func(seat, base int) int {
|
||||
if base == 0 {
|
||||
return 0
|
||||
}
|
||||
return base + sides[(seat+1)%n].ailmentBoost
|
||||
}
|
||||
|
||||
// hitUnit applies one attack against a seat's pet: shields block the
|
||||
// whole hit, Garlic shaves per-attack damage. Returns the damage dealt and,
|
||||
// when a shield absorbed the hit, a shieldBlock describing it (nil
|
||||
@@ -458,6 +612,9 @@ func (g *Game) runBattle() *BattleResult {
|
||||
if u == nil || amount <= 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
// Unicorn pack: Exposed raises the incoming damage of a landed hit. A
|
||||
// shield still blocks the whole (raised) hit; Garlic/preventions shave it.
|
||||
amount += effAilment(seat, u.Exposed)
|
||||
if sides[seat].shields > 0 {
|
||||
sides[seat].shields--
|
||||
var card *Card
|
||||
@@ -539,6 +696,10 @@ func (g *Game) runBattle() *BattleResult {
|
||||
if isBee(u.Card) {
|
||||
s.beesFainted++
|
||||
}
|
||||
// Unicorn pack: a Fairy set aside BEFORE this faint recycles the fallen
|
||||
// pet to the bottom of the deck. Snapshot the count now so a Fairy arming
|
||||
// on THIS faint doesn't recycle itself.
|
||||
fairyBefore := len(s.fairyGuards)
|
||||
// Track distinct suits among friendly fainted pets (Honduran White
|
||||
// Bat). Bees and the Golden Retriever have no suit.
|
||||
if !isBee(u.Card) && u.Card.Suit != "" {
|
||||
@@ -551,6 +712,25 @@ func (g *Game) runBattle() *BattleResult {
|
||||
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
||||
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
||||
}
|
||||
// Unicorn pack: a set-aside Mandrake on the enemy side cancels this
|
||||
// pet's Faint ability (the pet still faints; its effects just don't run).
|
||||
if es := enemyOf(seat); len(es.negators) > 0 {
|
||||
hasFaint := false
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger == TriggerFaint {
|
||||
hasFaint = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFaint {
|
||||
m := es.negators[len(es.negators)-1]
|
||||
es.negators = es.negators[:len(es.negators)-1]
|
||||
emit(BattleEvent{Type: "release", Seat: (seat + 1) % n, Card: &m,
|
||||
Text: fmt.Sprintf("%s's Mandrake cancels %s's faint ability.", pname((seat+1)%n), u.Card.Name)})
|
||||
goto enemyReactions
|
||||
}
|
||||
}
|
||||
{
|
||||
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||
@@ -643,8 +823,79 @@ func (g *Game) runBattle() *BattleResult {
|
||||
case ActionPetAura:
|
||||
s.petBonus += e.count()
|
||||
setAside()
|
||||
case ActionGainMana:
|
||||
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionAddAilment:
|
||||
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
|
||||
case ActionNextRoundApple:
|
||||
// Banked for next round's hand; surfaced then in the shop log
|
||||
// rather than as a battle-board change now.
|
||||
s.nextRoundApples += e.count()
|
||||
case ActionNegateEnemyFaint:
|
||||
s.negators = append(s.negators, u.Card)
|
||||
setAside()
|
||||
case ActionReviveSelf:
|
||||
// Slime: put a plain copy back on top of the deck — no faint
|
||||
// ability, so it can't loop. "Once per round" falls out of that.
|
||||
revived := u.Card
|
||||
revived.ID = g.newCardID()
|
||||
revived.Effects = nil
|
||||
revived.EffectText = ""
|
||||
summon(seat, revived, cause)
|
||||
case ActionAilmentToApples:
|
||||
s.unicornGuards = append(s.unicornGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSmallPetAura:
|
||||
s.smallPetBonus += e.count()
|
||||
setAside()
|
||||
case ActionAilmentBoost:
|
||||
s.ailmentBoost += e.count()
|
||||
setAside()
|
||||
case ActionManaFeedOnPlay:
|
||||
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionReviveNextFaint:
|
||||
s.fairyGuards = append(s.fairyGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSummonFromDiscard:
|
||||
// Chimera: add Count random cards from the FromTier discard pile as
|
||||
// temporary copies on top of the deck.
|
||||
pile := g.Discards[e.FromTier]
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if len(pile) == 0 {
|
||||
break
|
||||
}
|
||||
pick := pile[g.battleDraw(len(pile))]
|
||||
copyC := pick
|
||||
copyC.ID = g.newCardID()
|
||||
copyC.Temporary = true
|
||||
summon(seat, copyC, cause)
|
||||
}
|
||||
case ActionSummonFromTierDeck:
|
||||
// Pixiu: a temporary copy of the top of the FromTier shop deck.
|
||||
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
|
||||
deck := g.ShopDecks[e.FromTier-1]
|
||||
if len(deck) > 0 {
|
||||
copyC := deck[0]
|
||||
copyC.ID = g.newCardID()
|
||||
copyC.Temporary = true
|
||||
summon(seat, copyC, cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enemyReactions:
|
||||
// Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck
|
||||
// bottom (a fresh copy, so it re-enters later and can faint again).
|
||||
if fairyBefore > 0 {
|
||||
s.fairyGuards = s.fairyGuards[:len(s.fairyGuards)-1]
|
||||
revived := u.Card
|
||||
revived.ID = g.newCardID()
|
||||
s.stack = append(s.stack, revived)
|
||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &revived,
|
||||
Text: fmt.Sprintf("A set-aside Fairy sends %s to the bottom of %s's deck.", u.Card.Name, pname(seat))})
|
||||
}
|
||||
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
|
||||
for other, os := range sides {
|
||||
if other == seat || os.unit == nil || !os.unit.Alive() {
|
||||
@@ -699,20 +950,32 @@ func (g *Game) runBattle() *BattleResult {
|
||||
for range e.count() {
|
||||
u.Shields = append(u.Shields, shieldCharge{})
|
||||
}
|
||||
case ActionHeal:
|
||||
// Unicorn pack: Health Potion perk removes damage markers.
|
||||
healed := min(e.count(), u.Damage)
|
||||
if healed > 0 {
|
||||
u.Damage -= healed
|
||||
emit(BattleEvent{Type: "heal", Seat: seat, DamageAfter: u.Damage,
|
||||
Text: fmt.Sprintf("%s heals %d after being hurt.", u.Card.Name, healed)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// afterAttack fires After-Attack effects on a pet that survived a clash it
|
||||
// fought in (Bulldog eats an apple). Effects here are once per battle.
|
||||
// fought in (Bulldog eats an apple once per battle; Behemoth every clash).
|
||||
afterAttack := func(seat int, u *BattleUnit) {
|
||||
if !u.Alive() || u.afterAttackUsed {
|
||||
if !u.Alive() {
|
||||
return
|
||||
}
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
// Bulldog's charge is once per battle; Behemoth's fires every clash.
|
||||
if e.Once && u.afterAttackUsed {
|
||||
continue
|
||||
}
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
@@ -725,24 +988,30 @@ func (g *Game) runBattle() *BattleResult {
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
|
||||
}
|
||||
u.afterAttackUsed = true
|
||||
if e.Once {
|
||||
u.afterAttackUsed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
|
||||
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
||||
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
||||
// not "attacks with" the pet, so no knockout applies. Reports a kill and how
|
||||
// many dice came up blank (a 0), for Tatzelwurm's mana payout.
|
||||
throwRocks := func(from, target, dice int, source *Card) (killed bool, blanks int, dealt int) {
|
||||
tu := sides[target].unit
|
||||
if tu == nil || dice <= 0 {
|
||||
// No target, or a Per-scaled volley that came out to zero (Royal
|
||||
// Flycatcher / Grizzly with no fainted pets): nothing to animate.
|
||||
return false
|
||||
return false, 0, 0
|
||||
}
|
||||
roll := 0
|
||||
faces := make([]int, dice)
|
||||
for i := range dice {
|
||||
faces[i] = g.rollRockDie()
|
||||
roll += faces[i]
|
||||
if faces[i] == 0 {
|
||||
blanks++
|
||||
}
|
||||
}
|
||||
dealt, block, prev := hitUnit(target, roll)
|
||||
died := !tu.Alive()
|
||||
@@ -790,12 +1059,12 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
faint(target, tu)
|
||||
sides[target].unit = nil
|
||||
return true
|
||||
return true, blanks, dealt
|
||||
}
|
||||
if dealt > 0 {
|
||||
hurt(target, tu)
|
||||
}
|
||||
return false
|
||||
return false, blanks, dealt
|
||||
}
|
||||
|
||||
// nextTarget finds the seat whose pet a standard enemy-directed play
|
||||
@@ -837,12 +1106,52 @@ func (g *Game) runBattle() *BattleResult {
|
||||
s.pending = append(s.pending, c)
|
||||
continue
|
||||
}
|
||||
if c.IsAilment() {
|
||||
// Unicorn pack: an ailment on top of the deck (Frost Wolf, Slime)
|
||||
// waits to attach to the next pet revealed on this side.
|
||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
|
||||
Text: fmt.Sprintf("%s's %s waits for the next pet.", pname(seat), c.Name)})
|
||||
s.pendingAilments = append(s.pendingAilments, c)
|
||||
continue
|
||||
}
|
||||
// Unicorn pack: Sleipnir's base Power becomes the owner's Mana
|
||||
// (capped) at the moment it is played.
|
||||
for _, e := range c.Effects {
|
||||
if e.Trigger == TriggerPassive && e.Action == ActionManaPower {
|
||||
c.Power = s.mana
|
||||
if e.Cap > 0 && c.Power > e.Cap {
|
||||
c.Power = e.Cap
|
||||
}
|
||||
}
|
||||
}
|
||||
u := &BattleUnit{Card: c, Foods: s.pending}
|
||||
u.Bonus += appleCount(s.pending)
|
||||
u.Bonus += s.petBonus
|
||||
if isBee(c) {
|
||||
u.Bonus += s.beeBonus
|
||||
}
|
||||
// Unicorn pack: Rootlin buffs small pets (base Power <= 2).
|
||||
if c.Power <= 2 {
|
||||
u.Bonus += s.smallPetBonus
|
||||
}
|
||||
// Unicorn pack: Baku discards the first ailment it would take.
|
||||
for _, e := range c.Effects {
|
||||
if e.Trigger == TriggerPassive && e.Action == ActionAilmentImmune {
|
||||
u.bakuGuard = true
|
||||
}
|
||||
}
|
||||
// Ailments waiting on this deck attach now, respecting Baku's
|
||||
// guard; each landing is narrated so the client updates its badges.
|
||||
for _, a := range s.pendingAilments {
|
||||
landed := 0
|
||||
if u.takeAilment(a.Ailment) {
|
||||
landed = 1
|
||||
}
|
||||
ac := a
|
||||
emit(BattleEvent{Type: "ailment", Seat: seat, Card: &ac, Count: landed,
|
||||
Text: fmt.Sprintf("%s's %s latches onto %s.", pname(seat), a.Name, c.Name)})
|
||||
}
|
||||
s.pendingAilments = nil
|
||||
// Pets carry their starting bonus (foods + auras) in the
|
||||
// reveal event so clients can display it directly.
|
||||
revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name)
|
||||
@@ -892,6 +1201,23 @@ func (g *Game) runBattle() *BattleResult {
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)})
|
||||
}
|
||||
// Unicorn pack: Team Spirit spends 1 Mana per set-aside to feed the
|
||||
// just-played pet its apples (mandatory when affordable).
|
||||
for i := range s.manaFeed {
|
||||
if s.mana <= 0 {
|
||||
break
|
||||
}
|
||||
s.mana--
|
||||
src := s.manaFeed[i].src
|
||||
emit(BattleEvent{Type: "mana", Seat: seat, Count: -1,
|
||||
Text: fmt.Sprintf("%s spends 1 Mana.", src.Name)})
|
||||
for range s.manaFeed[i].apples {
|
||||
u.Foods = append(u.Foods, g.newApple())
|
||||
u.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.manaFeed[i].apples, u.Bonus)})
|
||||
}
|
||||
// Walk the pet's own play effects, then its active perk's, so a
|
||||
// play-time shield knows its source (an innate pet block vs a
|
||||
// Melon perk that should be shown and later dropped).
|
||||
@@ -1020,18 +1346,18 @@ func (g *Game) runBattle() *BattleResult {
|
||||
switch {
|
||||
case q.everyone:
|
||||
for seat := range sides {
|
||||
if throwRocks(q.seat, seat, dice, q.source) {
|
||||
if killed, _, _ := throwRocks(q.seat, seat, dice, q.source); killed {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
case q.effect.Target == "self":
|
||||
// Manatee pelts its own pet.
|
||||
if throwRocks(q.seat, q.seat, dice, q.source) {
|
||||
if killed, _, _ := throwRocks(q.seat, q.seat, dice, q.source); killed {
|
||||
anyDeath = true
|
||||
}
|
||||
default:
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
if throwRocks(q.seat, t, dice, q.source) {
|
||||
if killed, _, _ := throwRocks(q.seat, t, dice, q.source); killed {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
@@ -1039,6 +1365,29 @@ func (g *Game) runBattle() *BattleResult {
|
||||
if q.release != nil {
|
||||
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||||
}
|
||||
case ActionThrowRockGainMana:
|
||||
// Tatzelwurm: throw the rocks, then gain 1 Mana per blank rolled.
|
||||
dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
killed, blanks, _ := throwRocks(q.seat, t, dice, q.source)
|
||||
if killed {
|
||||
anyDeath = true
|
||||
}
|
||||
gainMana(q.seat, blanks, costName)
|
||||
}
|
||||
case ActionGainMana:
|
||||
gainMana(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||||
case ActionAddAilment:
|
||||
addAilment(q.seat, q.effect.Ailment, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), q.effect.Target == "enemyDeck", costName)
|
||||
// Calygreyhound also eats apples alongside the spook.
|
||||
if q.effect.Eat > 0 && q.unit != nil {
|
||||
for range q.effect.Eat {
|
||||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||||
q.unit.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||||
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
|
||||
}
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||||
case ActionDoubleTrumpets:
|
||||
@@ -1159,6 +1508,13 @@ func (g *Game) runBattle() *BattleResult {
|
||||
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||||
}
|
||||
case ActionEatApple:
|
||||
// Unicorn pack: Mothman only eats if the enemy pet is ailing.
|
||||
if q.effect.Condition == ConditionEnemyAilment {
|
||||
et := nextTarget(q.seat)
|
||||
if et < 0 || sides[et].unit == nil || !sides[et].unit.hasAilment() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||
if count > 0 {
|
||||
for range count {
|
||||
@@ -1193,11 +1549,111 @@ func (g *Game) runBattle() *BattleResult {
|
||||
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
|
||||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
if throwRocks(q.seat, t, 2*choice, nil) {
|
||||
if killed, _, _ := throwRocks(q.seat, t, 2*choice, nil); killed {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case ActionRockThenEat:
|
||||
// Vampire Bat: (if the enemy is ailing) throw rocks, then eat apples
|
||||
// equal to the damage those rocks actually dealt (Exposed counts).
|
||||
t := nextTarget(q.seat)
|
||||
if t < 0 {
|
||||
continue
|
||||
}
|
||||
if q.effect.Condition == ConditionEnemyAilment && !sides[t].unit.hasAilment() {
|
||||
continue
|
||||
}
|
||||
dice := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||
killed, _, dealt := throwRocks(q.seat, t, dice, nil)
|
||||
if killed {
|
||||
anyDeath = true
|
||||
}
|
||||
if dealt > 0 && sides[q.seat].unit == q.unit {
|
||||
for range dealt {
|
||||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||||
q.unit.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||||
Text: fmt.Sprintf("%s eats %d apple%s from the damage dealt (now +%d).", q.unit.Card.Name, dealt, plural(dealt), q.unit.Bonus)})
|
||||
}
|
||||
case ActionBounceEnemy:
|
||||
// Loveland Frogman: send the enemy pet to the bottom of the enemy
|
||||
// deck as a fresh body with its Play ability stripped (so two of them
|
||||
// can't bounce each other forever).
|
||||
t := nextTarget(q.seat)
|
||||
if t < 0 {
|
||||
continue
|
||||
}
|
||||
tu := sides[t].unit
|
||||
bounced := tu.Card
|
||||
bounced.ID = g.newCardID()
|
||||
var keep []Effect
|
||||
for _, ef := range bounced.Effects {
|
||||
if ef.Trigger != TriggerPlay {
|
||||
keep = append(keep, ef)
|
||||
}
|
||||
}
|
||||
bounced.Effects = keep
|
||||
sides[t].stack = append(sides[t].stack, bounced)
|
||||
sides[t].unit = nil
|
||||
emit(BattleEvent{Type: "bounce", Seat: q.seat, Target: t, Card: &bounced,
|
||||
Text: fmt.Sprintf("%s sends %s to the bottom of %s's deck.", q.unit.Card.Name, tu.Card.Name, pname(t))})
|
||||
anyDeath = true // force a refill before the clash
|
||||
case ActionSpendManaRocks:
|
||||
// Sea Serpent: spend all Mana to throw that many rocks.
|
||||
s := sides[q.seat]
|
||||
choice := s.mana
|
||||
if choice > 0 {
|
||||
s.mana -= choice
|
||||
emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice,
|
||||
Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)})
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
if killed, _, _ := throwRocks(q.seat, t, choice, nil); killed {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case ActionSpendManaSpook:
|
||||
// Bakunawa: spend all Mana to add that many Spooked to the enemy pet.
|
||||
s := sides[q.seat]
|
||||
choice := s.mana
|
||||
if choice > 0 {
|
||||
s.mana -= choice
|
||||
emit(BattleEvent{Type: "mana", Seat: q.seat, Count: -choice,
|
||||
Text: fmt.Sprintf("%s spends %d Mana.", q.unit.Card.Name, choice)})
|
||||
addAilment(q.seat, AilmentSpooked, choice, false, costName)
|
||||
}
|
||||
case ActionGainAbility:
|
||||
// Abomination: copy a random pet's effects from the highest tier
|
||||
// discard pile. Gained abilities fire on their natural triggers going
|
||||
// forward; a gained Play ability doesn't retro-fire this turn.
|
||||
bestTier := 0
|
||||
for tier, pile := range g.Discards {
|
||||
if tier <= bestTier {
|
||||
continue
|
||||
}
|
||||
for _, dc := range pile {
|
||||
if dc.IsPet() {
|
||||
bestTier = tier
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestTier > 0 {
|
||||
var pets []Card
|
||||
for _, dc := range g.Discards[bestTier] {
|
||||
if dc.IsPet() {
|
||||
pets = append(pets, dc)
|
||||
}
|
||||
}
|
||||
if len(pets) > 0 {
|
||||
pick := pets[g.battleDraw(len(pets))]
|
||||
q.unit.Card.Effects = append(append([]Effect(nil), q.unit.Card.Effects...), pick.Effects...)
|
||||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||||
Text: fmt.Sprintf("%s mimics %s's ability.", q.unit.Card.Name, pick.Name)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Battle over? A side that can no longer field a pet is out (checked
|
||||
@@ -1219,7 +1675,10 @@ func (g *Game) runBattle() *BattleResult {
|
||||
// Clash. Two-player for now; >2-player battle pairings come later
|
||||
// (the surrounding state is already per-seat).
|
||||
ua, ub := sides[0].unit, sides[1].unit
|
||||
powA, powB := ua.Power(), ub.Power()
|
||||
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
|
||||
// to the defender inside hitUnit); a Manticore boosts enemy ailments.
|
||||
powA := max(0, ua.Power()-effAilment(0, ua.Spooked))
|
||||
powB := max(0, ub.Power()-effAilment(1, ub.Spooked))
|
||||
dealtA, blockA, prevA := hitUnit(0, powB)
|
||||
dealtB, blockB, prevB := hitUnit(1, powA)
|
||||
// Scorpion: a clash attack that hurts, KOs.
|
||||
@@ -1321,6 +1780,8 @@ func (g *Game) runBattle() *BattleResult {
|
||||
// Record each side's leftover force: a live pet in play plus any pets never
|
||||
// reached in the stack. The loser lands on 0.
|
||||
res.Survivors = make([]int, n)
|
||||
res.ManaAfter = make([]int, n)
|
||||
res.NextRoundApples = make([]int, n)
|
||||
for seat, s := range sides {
|
||||
cnt := 0
|
||||
if s.unit != nil && s.unit.Alive() {
|
||||
@@ -1332,6 +1793,8 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
}
|
||||
res.Survivors[seat] = cnt
|
||||
res.ManaAfter[seat] = s.mana
|
||||
res.NextRoundApples[seat] = s.nextRoundApples
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
+440
-3
@@ -19,6 +19,9 @@ type CardKind string
|
||||
const (
|
||||
KindPet CardKind = "pet"
|
||||
KindFood CardKind = "food"
|
||||
// KindAilment (Unicorn pack) is a temporary debuff card (Spooked, Exposed)
|
||||
// attached to a pet during battle. It is neither a pet nor a food.
|
||||
KindAilment CardKind = "ailment"
|
||||
)
|
||||
|
||||
// Food identifiers.
|
||||
@@ -37,6 +40,15 @@ const (
|
||||
FoodPotato = "potato"
|
||||
FoodDurian = "durian"
|
||||
FoodTomato = "tomato"
|
||||
// FoodFairyDust (Unicorn pack) is a perk food: the pet it's on gains 1 Mana
|
||||
// when played. FoodWaterOfYouth is a one-shot shop food (not a perk) that
|
||||
// upgrades a pet into a next-tier card.
|
||||
FoodFairyDust = "fairyDust"
|
||||
FoodWaterOfYouth = "waterOfYouth"
|
||||
// Unicorn pack perk foods, tiers 4-6.
|
||||
FoodHealthPotion = "healthPotion" // Hurt: heal 3 damage
|
||||
FoodBigManaPotion = "bigManaPotion" // Play: gain 3 Mana
|
||||
FoodCornucopia = "cornucopia" // Play: eat 2 Apples, add 2 on deck
|
||||
)
|
||||
|
||||
// EffectTrigger is when an effect fires.
|
||||
@@ -209,6 +221,91 @@ const (
|
||||
// Retriever, when summoned, prevents Count damage on its first hit (German
|
||||
// Shepherd).
|
||||
ActionGuardRetriever EffectAction = "guardRetriever"
|
||||
|
||||
// --- Unicorn pack ---
|
||||
// ActionGainMana adds Count Mana to the acting side. Mana is a persistent
|
||||
// resource (unlike Trumpets): it is gained shop-time (Buy: Cuddle Toad,
|
||||
// Thunderbird) or battle-time (Play/Faint: Alchemedes, Fur-Bearing Trout),
|
||||
// kept between rounds, and spent via CostMana (see Effect.CostMana).
|
||||
ActionGainMana EffectAction = "gainMana"
|
||||
// ActionAddAilment attaches Count Ailment cards (Effect.Ailment: "spooked" |
|
||||
// "exposed") to the enemy. Target "" hits the enemy pet in play; Target
|
||||
// "enemyDeck" puts them on top of the enemy deck to snare the next pet. When
|
||||
// Eat > 0 (Calygreyhound) the pet also eats that many apples. Battle-time.
|
||||
ActionAddAilment EffectAction = "addAilment"
|
||||
// ActionAilmentImmune (passive) makes the pet discard the first Ailment it
|
||||
// would receive (or one it enters play with) — Baku.
|
||||
ActionAilmentImmune EffectAction = "ailmentImmune"
|
||||
// ActionThrowRockGainMana (play) throws Count rocks at the enemy pet, then
|
||||
// gains 1 Mana for each rock that rolled a blank (a 0) — Tatzelwurm.
|
||||
ActionThrowRockGainMana EffectAction = "throwRockGainMana"
|
||||
// ActionReviveSelf (faint) puts a plain copy of this pet back on top of its
|
||||
// owner's deck once per battle (the copy carries no faint ability, so it
|
||||
// can't loop) — Slime.
|
||||
ActionReviveSelf EffectAction = "reviveSelf"
|
||||
// ActionNegateEnemyFaint (faint) sets the pet aside: the next enemy pet to
|
||||
// faint has its Faint ability do nothing — Mandrake.
|
||||
ActionNegateEnemyFaint EffectAction = "negateEnemyFaint"
|
||||
// ActionNextRoundApple (faint) banks Count apples that land in the owner's
|
||||
// hand at the start of the next round — Skeleton Dog.
|
||||
ActionNextRoundApple EffectAction = "nextRoundApple"
|
||||
// ActionPeekShop (passive) marks a pet whose owner may reveal it from hand to
|
||||
// look at the top of the current shop deck, once per round — Bigfoot. It is
|
||||
// an optional shop action (see Game.PeekShopDeck), never auto-fired.
|
||||
ActionPeekShop EffectAction = "peekShop"
|
||||
// ActionUpgradeNextTier (buy) discards this food and a pet from the player's
|
||||
// hand to take the top card of the next tier's deck for free — Water of
|
||||
// Youth. Shop-time; opens a PendingSacrifice choice.
|
||||
ActionUpgradeNextTier EffectAction = "upgradeNextTier"
|
||||
|
||||
// --- Unicorn pack, tiers 4-6 ---
|
||||
// ActionAilmentToApples (faint) sets the pet aside and arms a side guard: the
|
||||
// next Ailment that would land on a friendly pet or friendly deck is replaced
|
||||
// by 2 Apples on top of the owner's deck — Unicorn.
|
||||
ActionAilmentToApples EffectAction = "ailmentToApples"
|
||||
// ActionSmallPetAura (faint) sets the pet aside: friendly pets with base
|
||||
// Power <= 2 get +Count Power for the rest of the battle — Rootlin.
|
||||
ActionSmallPetAura EffectAction = "smallPetAura"
|
||||
// ActionReviveNextFaint (faint) sets the pet aside (paying CostMana): the next
|
||||
// time a friendly pet faints, a copy goes on the bottom of the owner's deck —
|
||||
// Fairy.
|
||||
ActionReviveNextFaint EffectAction = "reviveNextFaint"
|
||||
// ActionGainAbility (play) reveals a random pet from the highest tier with
|
||||
// discards and copies its effects onto this pet — Abomination.
|
||||
ActionGainAbility EffectAction = "gainAbility"
|
||||
// ActionSummonFromDiscard (faint, paying CostMana) adds Count random cards
|
||||
// from the FromTier discard pile as temporary copies on top of the deck —
|
||||
// Chimera.
|
||||
ActionSummonFromDiscard EffectAction = "summonFromDiscard"
|
||||
// ActionSummonFromTierDeck (faint, paying CostMana) adds a temporary copy of
|
||||
// the top of the FromTier shop deck on top of the owner's deck — Pixiu.
|
||||
ActionSummonFromTierDeck EffectAction = "summonFromTierDeck"
|
||||
// ActionRockThenEat (play) throws Count rocks at the enemy pet, then feeds
|
||||
// this pet Apples equal to the damage those rocks actually dealt (Exposed
|
||||
// counts) — Vampire Bat. Usually gated on ConditionEnemyAilment.
|
||||
ActionRockThenEat EffectAction = "rockThenEat"
|
||||
// ActionBounceEnemy (play) sends the enemy pet to the bottom of the enemy
|
||||
// deck as a fresh body (its Play ability stripped so it can't re-loop) —
|
||||
// Loveland Frogman.
|
||||
ActionBounceEnemy EffectAction = "bounceEnemy"
|
||||
// ActionManaPower (passive) sets the pet's base Power to the owner's Mana at
|
||||
// play time, capped at Cap — Sleipnir.
|
||||
ActionManaPower EffectAction = "manaPower"
|
||||
// ActionSpendManaRocks (play) spends all the owner's Mana to throw that many
|
||||
// rocks at the enemy pet — Sea Serpent.
|
||||
ActionSpendManaRocks EffectAction = "spendManaRocks"
|
||||
// ActionSpendManaSpook (play) spends all the owner's Mana to add that many
|
||||
// Spooked to the enemy pet — Bakunawa.
|
||||
ActionSpendManaSpook EffectAction = "spendManaSpook"
|
||||
// ActionAilmentBoost (faint) sets the pet aside: Ailments on enemy pets count
|
||||
// for Count more — Manticore.
|
||||
ActionAilmentBoost EffectAction = "ailmentBoost"
|
||||
// ActionManaFeedOnPlay (faint) sets the pet aside: each time the owner plays a
|
||||
// pet, spend 1 Mana to feed it Count Apples — Team Spirit.
|
||||
ActionManaFeedOnPlay EffectAction = "manaFeedOnPlay"
|
||||
// ActionRevealTierForApples (buy) opens a reveal of a hand pet at tier <= Cap
|
||||
// for Count Apples — Quetzalcoatl. Shop-time.
|
||||
ActionRevealTierForApples EffectAction = "revealTierForApples"
|
||||
)
|
||||
|
||||
// Per multipliers for dynamic effect counts.
|
||||
@@ -236,6 +333,19 @@ const (
|
||||
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
|
||||
ConditionHasPerk = "hasPerk" // this pet has a perk attached
|
||||
ConditionFirstPet = "firstPet" // this is the first pet the side has played (Komodo)
|
||||
// ConditionEnemyAilment gates a battle effect on the enemy pet in play
|
||||
// carrying at least one Ailment (Unicorn pack: Mothman).
|
||||
ConditionEnemyAilment = "enemyAilment"
|
||||
// ConditionEvenRound gates a battle effect to even-numbered rounds (Unicorn
|
||||
// pack: Werewolf).
|
||||
ConditionEvenRound = "evenRound"
|
||||
)
|
||||
|
||||
// Ailment kinds (Unicorn pack). Ailments are temporary debuff cards attached to
|
||||
// a pet during battle; they stack and are removed at the end of the round.
|
||||
const (
|
||||
AilmentSpooked = "spooked" // the pet deals N less damage in a clash (min 0)
|
||||
AilmentExposed = "exposed" // the pet takes N extra damage each time it is hit
|
||||
)
|
||||
|
||||
// Effect is one trigger→action pair printed on a card.
|
||||
@@ -257,6 +367,22 @@ type Effect struct {
|
||||
// effect to fire (Golden pack). It's auto-paid when affordable and the
|
||||
// effect is skipped otherwise — battles take no player input.
|
||||
CostTrumpet int `json:"costTrumpet,omitempty"`
|
||||
// CostMana, when > 0, is a Mana cost paid the same way as CostTrumpet
|
||||
// (Unicorn pack). Unlike Trumpets, Mana persists across rounds.
|
||||
CostMana int `json:"costMana,omitempty"`
|
||||
// Ailment names the debuff an ActionAddAilment applies ("spooked" |
|
||||
// "exposed"); Count is how many (Unicorn pack).
|
||||
Ailment string `json:"ailment,omitempty"`
|
||||
// Eat, on an ActionAddAilment, also feeds the pet that many apples
|
||||
// (Calygreyhound spends 1 Mana to eat and spook in one ability).
|
||||
Eat int `json:"eat,omitempty"`
|
||||
// FromTier names a tier a battle effect pulls from — a discard pile (Chimera)
|
||||
// or a shop deck (Pixiu) — 1-based (Unicorn pack).
|
||||
FromTier int `json:"fromTier,omitempty"`
|
||||
// Once marks an After-Attack effect that fires at most once per battle
|
||||
// (Bulldog); without it the effect fires after every clash survived
|
||||
// (Behemoth).
|
||||
Once bool `json:"once,omitempty"`
|
||||
// SurviveOnly marks a Hurt effect that fires only when the pet lives through
|
||||
// the hit. By default a Hurt effect still fires on a fatal hit (the Lizard
|
||||
// drops its Bee even when killed); self-buffs that are pointless once the
|
||||
@@ -295,10 +421,14 @@ type Card struct {
|
||||
// Temporary cards (apples, bees) are removed from the deck after the
|
||||
// next battle.
|
||||
Temporary bool `json:"temporary,omitempty"`
|
||||
// Ailment marks a debuff card (Unicorn pack): "spooked" | "exposed". Set
|
||||
// together with Kind == KindAilment.
|
||||
Ailment string `json:"ailment,omitempty"`
|
||||
}
|
||||
|
||||
func (c Card) IsPet() bool { return c.Kind == KindPet }
|
||||
func (c Card) IsFood() bool { return c.Kind == KindFood }
|
||||
func (c Card) IsPet() bool { return c.Kind == KindPet }
|
||||
func (c Card) IsFood() bool { return c.Kind == KindFood }
|
||||
func (c Card) IsAilment() bool { return c.Kind == KindAilment }
|
||||
|
||||
// petTemplate is the printed definition of a pet. Suits lists the suit of
|
||||
// each physical copy in the tier deck (one card per entry).
|
||||
@@ -613,7 +743,7 @@ var goldenPetTiers = [MaxRounds][]petTemplate{
|
||||
},
|
||||
{
|
||||
Name: "Bulldog", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple}},
|
||||
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Once: true}},
|
||||
EffectText: "After Attacking: if it hasn't fainted, eat 1 Apple (once per round)",
|
||||
},
|
||||
{
|
||||
@@ -853,6 +983,294 @@ var goldenFoodTiers = [MaxRounds][]foodTemplate{
|
||||
},
|
||||
}
|
||||
|
||||
// unicornPetTiers defines the Unicorn pack's pets. Tiers 1-3 are printed;
|
||||
// tiers 4-6 are still being authored. Each pet ships as two copies (one per
|
||||
// listed suit). The pack adds two mechanics the others don't have: Mana (a
|
||||
// persistent resource spent to power abilities) and Ailments (Spooked/Exposed
|
||||
// debuffs attached to enemy pets during battle).
|
||||
var unicornPetTiers = [MaxRounds][]petTemplate{
|
||||
{ // Tier 1
|
||||
{
|
||||
Name: "Alchemedes", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}},
|
||||
EffectText: "Play: gain 1 Mana",
|
||||
},
|
||||
{
|
||||
Name: "Cuddle Toad", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana}},
|
||||
EffectText: "Buy: gain 1 Mana",
|
||||
},
|
||||
{
|
||||
Name: "Pengobble", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2, CostMana: 1}},
|
||||
EffectText: "Play: spend 1 Mana to throw 2 Rocks",
|
||||
},
|
||||
{
|
||||
Name: "Barghest", Power: 1, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked}},
|
||||
EffectText: "Play: add 1 Spooked to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Basilisk", Power: 1, Suits: []Suit{SuitRed, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed}},
|
||||
EffectText: "Play: add 1 Exposed to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Baku", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionAilmentImmune}},
|
||||
EffectText: "Discard the first Ailment added to this pet",
|
||||
},
|
||||
},
|
||||
{ // Tier 2
|
||||
{
|
||||
Name: "Thunderbird", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionGainMana, Count: 2}},
|
||||
EffectText: "Buy: gain 2 Mana",
|
||||
},
|
||||
{
|
||||
Name: "Gargoyle", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2, CostMana: 1}},
|
||||
EffectText: "Faint: spend 1 Mana to add 2 Apples on top of your deck",
|
||||
},
|
||||
{
|
||||
Name: "Frost Wolf", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed},
|
||||
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"},
|
||||
},
|
||||
EffectText: "Faint: add 1 Exposed to the enemy pet and 1 on top of the enemy deck",
|
||||
},
|
||||
{
|
||||
Name: "Mothman", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 2, Condition: ConditionEnemyAilment}},
|
||||
EffectText: "Play: if the enemy pet has an Ailment, eat 2 Apples",
|
||||
},
|
||||
{
|
||||
Name: "Nightcrawler", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2}},
|
||||
EffectText: "Play: add 2 Spooked to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Bigfoot", Power: 3, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionPeekShop}},
|
||||
EffectText: "Reveal from your hand to look at the top of the shop deck (once per round)",
|
||||
},
|
||||
},
|
||||
{ // Tier 3
|
||||
{
|
||||
Name: "Fur-Bearing Trout", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerFaint, Action: ActionGainMana},
|
||||
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple", Count: 2},
|
||||
},
|
||||
EffectText: "Faint: gain 1 Mana and add 2 Apples on top of your deck",
|
||||
},
|
||||
{
|
||||
Name: "Calygreyhound", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Eat: 1, CostMana: 1}},
|
||||
EffectText: "Play: spend 1 Mana to eat 1 Apple and add 1 Spooked to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Tatzelwurm", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRockGainMana, Count: 2}},
|
||||
EffectText: "Play: throw 2 Rocks, then gain 1 Mana for each blank rolled",
|
||||
},
|
||||
{
|
||||
Name: "Skeleton Dog", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "apple"},
|
||||
{Trigger: TriggerFaint, Action: ActionNextRoundApple},
|
||||
},
|
||||
EffectText: "Faint: add 1 Apple on top of your deck and 1 to your hand next round",
|
||||
},
|
||||
{
|
||||
Name: "Mandrake", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionNegateEnemyFaint}},
|
||||
EffectText: "Faint: set aside — the next enemy Faint ability does nothing",
|
||||
},
|
||||
{
|
||||
Name: "Lucky Cat", Power: 3, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerTriple, Action: ActionGainApple, Count: 3}},
|
||||
EffectText: "Triple: add 3 Apples to your hand",
|
||||
},
|
||||
{
|
||||
Name: "Slime", Power: 2, Suits: []Suit{SuitBlue, SuitRed},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerFaint, Action: ActionReviveSelf},
|
||||
{Trigger: TriggerFaint, Action: ActionAddAilment, Ailment: AilmentExposed, Target: "enemyDeck"},
|
||||
},
|
||||
EffectText: "Faint: put this pet on top of your deck and 1 Exposed on the enemy deck (once per round)",
|
||||
},
|
||||
},
|
||||
{ // Tier 4
|
||||
{
|
||||
Name: "Roc", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerPlay, Action: ActionGainMana},
|
||||
{Trigger: TriggerSell, Action: ActionGainMana},
|
||||
},
|
||||
EffectText: "Play: gain 1 Mana · Sell: gain 1 Mana",
|
||||
},
|
||||
{
|
||||
Name: "Chimera", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromDiscard, Count: 2, FromTier: 1, CostMana: 4}},
|
||||
EffectText: "Faint: spend 4 Mana to add 2 random cards from the tier 1 discard pile on top of your deck",
|
||||
},
|
||||
{
|
||||
Name: "Kraken", Power: 4, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked},
|
||||
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Target: "enemyDeck"},
|
||||
},
|
||||
EffectText: "Play: add 1 Spooked to the enemy pet and 1 on top of the enemy deck",
|
||||
},
|
||||
{
|
||||
Name: "Unicorn", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentToApples}},
|
||||
EffectText: "Faint: set aside — replace the next friendly Ailment with 2 Apples on your deck",
|
||||
},
|
||||
{
|
||||
Name: "Abomination", Power: 4, Suits: []Suit{SuitYellow, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainAbility}},
|
||||
EffectText: "Play: reveal a random pet from the highest tier discard pile and gain its ability",
|
||||
},
|
||||
{
|
||||
Name: "Rootlin", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSmallPetAura, Count: 1}},
|
||||
EffectText: "Faint: set aside — your pets with base Power 2 or less have +1 Power",
|
||||
},
|
||||
{
|
||||
Name: "Fairy", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionReviveNextFaint, CostMana: 3}},
|
||||
EffectText: "Faint: spend 3 Mana to set aside — the next friendly faint returns to the bottom of your deck",
|
||||
},
|
||||
},
|
||||
{ // Tier 5
|
||||
{
|
||||
Name: "Kitsune", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Per: PerFaintedPets}},
|
||||
EffectText: "Play: gain 1 Mana for each friendly fainted pet",
|
||||
},
|
||||
{
|
||||
Name: "Pixiu", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonFromTierDeck, FromTier: 6, CostMana: 4}},
|
||||
EffectText: "Faint: spend 4 Mana to add the top card of the tier 6 deck on top of your deck",
|
||||
},
|
||||
{
|
||||
Name: "Red Dragon", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2},
|
||||
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentExposed, Count: 2, Target: "enemyDeck"},
|
||||
{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 2},
|
||||
},
|
||||
EffectText: "Play: add 2 Exposed to the enemy pet and 2 on the enemy deck, then throw 2 Rocks",
|
||||
},
|
||||
{
|
||||
Name: "Amalgamation", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedPets},
|
||||
{Trigger: TriggerPlay, Action: ActionAddAilment, Ailment: AilmentSpooked, Count: 2},
|
||||
},
|
||||
EffectText: "Play: eat 1 Apple per friendly fainted pet, then add 2 Spooked to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Vampire Bat", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionRockThenEat, Count: 2, Condition: ConditionEnemyAilment}},
|
||||
EffectText: "Play: if the enemy pet has an Ailment, throw 2 Rocks, then eat Apples equal to the damage dealt",
|
||||
},
|
||||
{
|
||||
Name: "Werewolf", Power: 2, Suits: []Suit{SuitRed, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Count: 6, Condition: ConditionEvenRound}},
|
||||
EffectText: "Play: on an even-numbered round, eat 6 Apples",
|
||||
},
|
||||
{
|
||||
Name: "Loveland Frogman", Power: 2, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionBounceEnemy}},
|
||||
EffectText: "Play: put the enemy pet on the bottom of the enemy deck",
|
||||
},
|
||||
},
|
||||
{ // Tier 6
|
||||
{
|
||||
Name: "Sleipnir", Power: 0, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionManaPower, Cap: 9}},
|
||||
EffectText: "This pet's base Power equals your Mana (max 9)",
|
||||
},
|
||||
{
|
||||
Name: "Sea Serpent", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaRocks}},
|
||||
EffectText: "Play: spend any number of Mana to throw that many Rocks",
|
||||
},
|
||||
{
|
||||
Name: "Bakunawa", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSpendManaSpook}},
|
||||
EffectText: "Play: spend any number of Mana to add that many Spooked to the enemy pet",
|
||||
},
|
||||
{
|
||||
Name: "Manticore", Power: 6, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionAilmentBoost, Count: 1}},
|
||||
EffectText: "Faint: set aside — Ailments on enemy pets count for 1 more",
|
||||
},
|
||||
{
|
||||
Name: "Team Spirit", Power: 4, Suits: []Suit{SuitYellow, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionManaFeedOnPlay, Count: 2}},
|
||||
EffectText: "Faint: set aside — each pet you play, spend 1 Mana to feed it 2 Apples",
|
||||
},
|
||||
{
|
||||
Name: "Behemoth", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
|
||||
Effects: []Effect{{Trigger: TriggerAfterAttack, Action: ActionEatApple, Count: 2}},
|
||||
EffectText: "After Attacking: if it hasn't fainted, eat 2 Apples",
|
||||
},
|
||||
{
|
||||
Name: "Quetzalcoatl", Power: 6, Suits: []Suit{SuitRed, SuitBlue},
|
||||
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionRevealTierForApples, Count: 3, Cap: 3}},
|
||||
EffectText: "Buy: reveal a tier 3 or lower pet from your hand to gain 3 Apples",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// unicornFoodTiers defines the Unicorn pack's food cards (tiers 1-3 printed).
|
||||
var unicornFoodTiers = [MaxRounds][]foodTemplate{
|
||||
{}, // Tier 1
|
||||
{ // Tier 2
|
||||
{
|
||||
Name: "Fairy Dust", Food: FoodFairyDust, Copies: 2, Perk: true,
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana}},
|
||||
EffectText: "Play: gain 1 Mana",
|
||||
},
|
||||
},
|
||||
{ // Tier 3
|
||||
{
|
||||
Name: "Water of Youth", Food: FoodWaterOfYouth, Copies: 2,
|
||||
Effects: []Effect{{Trigger: TriggerBuy, Action: ActionUpgradeNextTier}},
|
||||
EffectText: "Buy: discard this and a pet to buy the top of the next tier for free",
|
||||
},
|
||||
},
|
||||
{ // Tier 4
|
||||
{
|
||||
Name: "Health Potion", Food: FoodHealthPotion, Copies: 2, Perk: true,
|
||||
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionHeal, Count: 3, SurviveOnly: true}},
|
||||
EffectText: "Hurt: if this pet hasn't fainted, heal 3 damage",
|
||||
},
|
||||
},
|
||||
{ // Tier 5
|
||||
{
|
||||
Name: "Big Mana Potion", Food: FoodBigManaPotion, Copies: 2, Perk: true,
|
||||
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionGainMana, Count: 3}},
|
||||
EffectText: "Play: gain 3 Mana",
|
||||
},
|
||||
},
|
||||
{ // Tier 6
|
||||
{
|
||||
Name: "Cornucopia", Food: FoodCornucopia, Copies: 2, Perk: true,
|
||||
Effects: []Effect{
|
||||
{Trigger: TriggerPlay, Action: ActionEatApple, Count: 2},
|
||||
{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 2},
|
||||
},
|
||||
EffectText: "Play: eat 2 Apples and add 2 Apples on top of your deck",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// newCardID mints a unique card ID within the game.
|
||||
func (g *Game) newCardID() string {
|
||||
g.NextCardID++
|
||||
@@ -866,6 +1284,8 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla
|
||||
switch pack {
|
||||
case "golden":
|
||||
return &goldenPetTiers, &goldenFoodTiers
|
||||
case "unicorn":
|
||||
return &unicornPetTiers, &unicornFoodTiers
|
||||
default: // turtle (and the placeholder packs, until they ship)
|
||||
return &petTiers, &foodTiers
|
||||
}
|
||||
@@ -981,6 +1401,23 @@ func (g *Game) newApple() Card {
|
||||
}
|
||||
}
|
||||
|
||||
// newAilment mints an ailment debuff card (Unicorn pack). It is temporary — it
|
||||
// leaves play at the end of the round, like an apple — and belongs to no deck
|
||||
// until an effect attaches it to a pet or drops it on a deck.
|
||||
func (g *Game) newAilment(kind string) Card {
|
||||
name := "Spooked"
|
||||
if kind == AilmentExposed {
|
||||
name = "Exposed"
|
||||
}
|
||||
return Card{
|
||||
ID: g.newCardID(),
|
||||
Kind: KindAilment,
|
||||
Name: name,
|
||||
Ailment: kind,
|
||||
Temporary: true,
|
||||
}
|
||||
}
|
||||
|
||||
// newBee mints a bee: a temporary 1-power pet with no effect, summoned by
|
||||
// other pets' effects. It counts as a pet while it exists (foods can attach
|
||||
// to it in battle).
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -34,6 +34,9 @@ const (
|
||||
LogSell = "sell" // Seat sold Source/CardName (it became an apple)
|
||||
LogTrade = "trade" // Seat traded in Cards for a next-tier pick
|
||||
LogTradePick = "tradePick" // Seat took their pick; CardName set when revealed
|
||||
// LogMana (Unicorn pack) tags a public shop-time Mana gain (Cuddle Toad,
|
||||
// Thunderbird), so observers can track the acting seat's Mana pool.
|
||||
LogMana = "mana"
|
||||
)
|
||||
|
||||
// addLog appends an entry, stamping it with the next sequence number and the
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package game
|
||||
|
||||
// Card packs are the selectable sets of pets and food a game is played with.
|
||||
// Turtle and Golden ship with full card data; Unicorn is declared here as
|
||||
// infrastructure (shown but not yet playable) so the lobby, views, and
|
||||
// deck-building all have a single source of truth to grow into.
|
||||
// Turtle, Golden, and Unicorn all ship with full six-tier card data.
|
||||
|
||||
// PackInfo describes one selectable pack. Playable gates whether a lobby may
|
||||
// choose it and start a game with it.
|
||||
@@ -21,7 +19,7 @@ const DefaultPack = "turtle"
|
||||
var Packs = []PackInfo{
|
||||
{ID: "turtle", Name: "Turtle Pack", Emoji: "🐢", Playable: true},
|
||||
{ID: "golden", Name: "Golden Pack", Emoji: "🥇", Playable: true},
|
||||
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: false},
|
||||
{ID: "unicorn", Name: "Unicorn Pack", Emoji: "🦄", Playable: true},
|
||||
}
|
||||
|
||||
// packByID looks up a pack, returning false if the id is unknown.
|
||||
|
||||
@@ -0,0 +1,939 @@
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// --- Unicorn pack test helpers ---
|
||||
|
||||
// unicornGame builds a started 2-player game on the Unicorn pack, bypassing the
|
||||
// lobby and the Playable gate (tiers 4-6 aren't printed yet).
|
||||
func unicornGame(t *testing.T) (*Game, *Player, *Player) {
|
||||
t.Helper()
|
||||
g := New()
|
||||
g.Pack = "unicorn"
|
||||
g.buildDecks()
|
||||
p1, err := g.AddPlayer("Alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := g.AddPlayer("Bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.start()
|
||||
g.PrioritySeat = p1.Seat
|
||||
g.Turn = p1.Seat
|
||||
return g, p1, p2
|
||||
}
|
||||
|
||||
// unicornPet mints a copy of a Unicorn pack pet (with effects) by name.
|
||||
func (g *Game) unicornPet(t *testing.T, name string) Card {
|
||||
t.Helper()
|
||||
for tierIdx, tier := range unicornPetTiers {
|
||||
for _, tmpl := range tier {
|
||||
if tmpl.Name == name {
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindPet, Name: tmpl.Name, Tier: tierIdx + 1,
|
||||
Power: tmpl.Power, Suit: tmpl.Suits[0],
|
||||
Effects: tmpl.Effects, EffectText: tmpl.EffectText,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no unicorn pet named %s", name)
|
||||
return Card{}
|
||||
}
|
||||
|
||||
// unicornFood mints a Unicorn pack food by name.
|
||||
func (g *Game) unicornFood(t *testing.T, name string) Card {
|
||||
t.Helper()
|
||||
for tierIdx, tier := range unicornFoodTiers {
|
||||
for _, f := range tier {
|
||||
if f.Name == name {
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no unicorn food named %s", name)
|
||||
return Card{}
|
||||
}
|
||||
|
||||
func manaEvents(res *BattleResult) []BattleEvent { return eventsOfType(res, "mana") }
|
||||
|
||||
// --- Mana ---
|
||||
|
||||
// Cuddle Toad's Buy grants persistent Mana that survives into later rounds.
|
||||
func TestCuddleToadShopMana(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
g.ShopRow[0] = g.unicornPet(t, "Cuddle Toad")
|
||||
if err := g.Buy(p1.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p1.Mana != 1 {
|
||||
t.Fatalf("Cuddle Toad should grant 1 Mana on buy, got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Thunderbird grants 2 Mana on buy.
|
||||
func TestThunderbirdShopMana(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
g.ShopRow[0] = g.unicornPet(t, "Thunderbird")
|
||||
if err := g.Buy(p1.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p1.Mana != 2 {
|
||||
t.Fatalf("Thunderbird should grant 2 Mana, got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Alchemedes gains Mana on play, and it persists on the player after the battle.
|
||||
func TestAlchemedesBattleManaPersists(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Alchemedes"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Weak", 1)},
|
||||
)
|
||||
gains := 0
|
||||
for _, ev := range manaEvents(res) {
|
||||
if ev.Seat == 0 && ev.Count > 0 {
|
||||
gains += ev.Count
|
||||
}
|
||||
}
|
||||
if gains != 1 {
|
||||
t.Fatalf("Alchemedes should gain 1 Mana in battle, got %d", gains)
|
||||
}
|
||||
if p1.Mana != 1 {
|
||||
t.Fatalf("Mana should persist on the player after battle, got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Pengobble spends banked Mana to throw 2 Rocks; with no Mana it does nothing.
|
||||
func TestPengobbleSpendsMana(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
g.RollDie = func() int { return 2 } // each rock deals 2
|
||||
p1.Mana = 1
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Pengobble")},
|
||||
[]Card{g.pet("Tank", 3)},
|
||||
)
|
||||
rocks := eventsOfType(res, "rock")
|
||||
if len(rocks) != 1 || rocks[0].Roll != 4 || !rocks[0].TargetDied {
|
||||
t.Fatalf("Pengobble should spend Mana to throw 2 rocks (4 dmg, kills 3-tank): %+v", rocks)
|
||||
}
|
||||
if p1.Mana != 0 {
|
||||
t.Fatalf("Pengobble should have spent the Mana, left %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPengobbleNoManaNoRocks(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.RollDie = func() int { return 2 }
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Pengobble")},
|
||||
[]Card{g.pet("Tank", 3)},
|
||||
)
|
||||
if rocks := eventsOfType(res, "rock"); len(rocks) != 0 {
|
||||
t.Fatalf("Pengobble with no Mana should throw no rocks, got %+v", rocks)
|
||||
}
|
||||
}
|
||||
|
||||
// Tatzelwurm gains 1 Mana per blank rolled while still dealing rock damage.
|
||||
func TestTatzelwurmManaOnBlanks(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
g.RollDie = func() int { return 0 } // both rocks blank
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Tatzelwurm"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Tank", 3)},
|
||||
)
|
||||
gains := 0
|
||||
for _, ev := range manaEvents(res) {
|
||||
if ev.Seat == 0 && ev.Count > 0 {
|
||||
gains += ev.Count
|
||||
}
|
||||
}
|
||||
if gains != 2 {
|
||||
t.Fatalf("Tatzelwurm should gain 2 Mana from 2 blanks, got %d", gains)
|
||||
}
|
||||
if p1.Mana != 2 {
|
||||
t.Fatalf("Tatzelwurm Mana should persist, got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Gargoyle spends Mana on faint to bank 2 apples on top of its deck.
|
||||
func TestGargoyleFaintSpendsMana(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.Players[0].Mana = 1
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Gargoyle")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
apples := 0
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
|
||||
apples++
|
||||
}
|
||||
}
|
||||
if apples != 2 {
|
||||
t.Fatalf("Gargoyle should summon 2 apples when it can pay Mana, got %d", apples)
|
||||
}
|
||||
}
|
||||
|
||||
// Fairy Dust (perk) grants its pet 1 Mana when the pet is played in battle.
|
||||
func TestFairyDustPerkMana(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornFood(t, "Fairy Dust"), g.pet("Body", 4)},
|
||||
[]Card{g.pet("Weak", 1)},
|
||||
)
|
||||
gains := 0
|
||||
for _, ev := range manaEvents(res) {
|
||||
if ev.Seat == 0 && ev.Count > 0 {
|
||||
gains += ev.Count
|
||||
}
|
||||
}
|
||||
if gains != 1 {
|
||||
t.Fatalf("Fairy Dust should grant 1 Mana on play, got %d", gains)
|
||||
}
|
||||
if p1.Mana != 1 {
|
||||
t.Fatalf("Fairy Dust Mana should persist, got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ailments ---
|
||||
|
||||
// Basilisk's Exposed makes the enemy pet take extra damage on each hit — a
|
||||
// 1-power Basilisk trades into a 2-power pet it could never otherwise kill.
|
||||
func TestBasiliskExposedKills(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Basilisk"), g.pet("Cleanup", 2)},
|
||||
[]Card{g.pet("Two", 2)},
|
||||
)
|
||||
if len(eventsOfType(res, "ailment")) == 0 {
|
||||
t.Fatal("expected an ailment event from Basilisk")
|
||||
}
|
||||
// Basilisk (1) + 1 Exposed deals 2 to Two (2) — it faints in the clash; the
|
||||
// cleanup pet then wins for seat 0.
|
||||
if res.WinnerSeat != 0 {
|
||||
t.Fatalf("Exposed should let Basilisk's side win, got winner %d", res.WinnerSeat)
|
||||
}
|
||||
}
|
||||
|
||||
// Nightcrawler's 2 Spooked cut the enemy pet's clash attack.
|
||||
func TestNightcrawlerSpookedReducesAttack(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
// Seat 0: Nightcrawler(1) spooks the enemy by 2, then a 3-power body.
|
||||
// Seat 1: a 3-power attacker. Spooked drops its attack to 1, so the body
|
||||
// survives and seat 0 wins.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Nightcrawler"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Bruiser", 3)},
|
||||
)
|
||||
if res.WinnerSeat != 0 {
|
||||
t.Fatalf("Spooked should weaken the Bruiser enough for seat 0 to win, got %d", res.WinnerSeat)
|
||||
}
|
||||
}
|
||||
|
||||
// Baku shrugs off the first ailment it would receive.
|
||||
func TestBakuDiscardsFirstAilment(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
// Seat 1 leads with Barghest (spooks Baku by 1) — Baku's guard eats it, so
|
||||
// Baku attacks at full power.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Baku")},
|
||||
[]Card{g.unicornPet(t, "Barghest"), g.pet("Body", 2)},
|
||||
)
|
||||
// Barghest (1) spooks Baku, but the guard cancels it. Baku(2) vs Barghest(1):
|
||||
// Baku deals 2, Barghest dies; Baku takes 1, survives. Then Baku(2, 1 dmg)
|
||||
// vs Body(2): Baku deals 2 → Body dies; Body deals 2 → Baku total 3 ≥ 2 dies.
|
||||
// Both empty ⇒ draw. Without the guard Baku would deal only 1 and lose. So a
|
||||
// non-loss for seat 0 proves the guard worked.
|
||||
if res.WinnerSeat == 1 {
|
||||
t.Fatal("Baku should not lose — its guard cancels Barghest's Spooked")
|
||||
}
|
||||
ail := eventsOfType(res, "ailment")
|
||||
if len(ail) != 1 || ail[0].Count != 0 {
|
||||
t.Fatalf("Baku's guard should record a shrugged-off ailment (count 0), got %+v", ail)
|
||||
}
|
||||
}
|
||||
|
||||
// Frost Wolf's faint exposes the enemy pet and drops another Exposed on top of
|
||||
// the enemy deck for the next pet.
|
||||
func TestFrostWolfExposes(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Frost Wolf")},
|
||||
[]Card{g.pet("A", 2), g.pet("B", 2)},
|
||||
)
|
||||
// One Exposed on the current enemy pet (an ailment event) and one on top of
|
||||
// the enemy deck (a summon of an ailment card onto seat 1).
|
||||
if len(eventsOfType(res, "ailment")) == 0 {
|
||||
t.Fatal("Frost Wolf should afflict the enemy pet in play")
|
||||
}
|
||||
deckAilment := false
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
|
||||
deckAilment = true
|
||||
}
|
||||
}
|
||||
if !deckAilment {
|
||||
t.Fatal("Frost Wolf should drop an Exposed on top of the enemy deck")
|
||||
}
|
||||
}
|
||||
|
||||
// Mothman eats 2 apples only when the enemy pet already carries an ailment.
|
||||
func TestMothmanEatsOnEnemyAilment(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
// Seat 1 leads with Basilisk, which exposes seat 0's first pet (Mothman).
|
||||
// Wait — Mothman checks the ENEMY's ailment. Put the ailment on the enemy:
|
||||
// seat 0 = [Barghest (spooks enemy), Mothman]; when Mothman enters, the enemy
|
||||
// pet is spooked, so Mothman eats.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Barghest"), g.unicornPet(t, "Mothman"), g.pet("Body", 5)},
|
||||
[]Card{g.pet("Tank", 8)},
|
||||
)
|
||||
ate := false
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 {
|
||||
ate = true
|
||||
}
|
||||
}
|
||||
if !ate {
|
||||
t.Fatal("Mothman should eat when the enemy pet is afflicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMothmanNoEatWithoutAilment(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Mothman"), g.pet("Body", 5)},
|
||||
[]Card{g.pet("Tank", 8)},
|
||||
)
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 {
|
||||
t.Fatal("Mothman should not eat when the enemy pet has no ailment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calygreyhound spends Mana to eat an apple and spook the enemy.
|
||||
func TestCalygreyhoundSpendsMana(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.Players[0].Mana = 1
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Calygreyhound"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Tank", 6)},
|
||||
)
|
||||
if len(eventsOfType(res, "ailment")) == 0 {
|
||||
t.Fatal("Calygreyhound should spook the enemy when it can pay Mana")
|
||||
}
|
||||
ate := false
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 {
|
||||
ate = true
|
||||
}
|
||||
}
|
||||
if !ate {
|
||||
t.Fatal("Calygreyhound should also eat an apple")
|
||||
}
|
||||
if g.Players[0].Mana != 0 {
|
||||
t.Fatalf("Calygreyhound should have spent the Mana, left %d", g.Players[0].Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Faint mechanics ---
|
||||
|
||||
// Fur-Bearing Trout gains Mana and banks 2 apples when it faints.
|
||||
func TestFurBearingTroutFaint(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Fur-Bearing Trout")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
if p1.Mana != 1 {
|
||||
t.Fatalf("Fur-Bearing Trout should bank 1 Mana on faint, got %d", p1.Mana)
|
||||
}
|
||||
apples := 0
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
|
||||
apples++
|
||||
}
|
||||
}
|
||||
if apples != 2 {
|
||||
t.Fatalf("Fur-Bearing Trout should summon 2 apples on faint, got %d", apples)
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton Dog banks an apple for the next round's hand.
|
||||
func TestSkeletonDogBanksNextRoundApple(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Skeleton Dog")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
if p1.NextRoundApples != 1 {
|
||||
t.Fatalf("Skeleton Dog should bank 1 next-round apple, got %d", p1.NextRoundApples)
|
||||
}
|
||||
// Acknowledge the battle and enter the next round; the apple should land.
|
||||
before := countFood(p1.Deck, FoodApple)
|
||||
g.AcknowledgeBattle(p1.ID)
|
||||
g.AcknowledgeBattle(g.Players[1].ID)
|
||||
if g.Phase != PhaseShop {
|
||||
t.Fatalf("expected a new shop round, got %s", g.Phase)
|
||||
}
|
||||
after := countFood(p1.Deck, FoodApple)
|
||||
if after != before+1 {
|
||||
t.Fatalf("banked apple should arrive next round: before=%d after=%d", before, after)
|
||||
}
|
||||
if p1.NextRoundApples != 0 {
|
||||
t.Fatalf("the bank should reset after paying out, got %d", p1.NextRoundApples)
|
||||
}
|
||||
}
|
||||
|
||||
func countFood(deck []Card, food string) int {
|
||||
n := 0
|
||||
for _, c := range deck {
|
||||
if c.Food == food {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Mandrake cancels the next enemy Faint ability. Mandrake must faint first to
|
||||
// arm the negation, so seat 0's Heavy kills it, then seat 0's Ant later faints
|
||||
// against seat 1's Big — and its apple is cancelled.
|
||||
func TestMandrakeNegatesEnemyFaint(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.pet("Heavy", 3), g.tier1(t, "Ant")},
|
||||
[]Card{g.unicornPet(t, "Mandrake"), g.pet("Big", 6)},
|
||||
)
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Food == FoodApple {
|
||||
t.Fatal("Ant's faint ability should have been cancelled by Mandrake")
|
||||
}
|
||||
}
|
||||
// Sanity: without the negation the Ant's apple would appear — confirm the
|
||||
// Mandrake actually got set aside and released as a cancel.
|
||||
sawCancel := false
|
||||
for _, ev := range eventsOfType(res, "release") {
|
||||
if ev.Card != nil && ev.Card.Name == "Mandrake" {
|
||||
sawCancel = true
|
||||
}
|
||||
}
|
||||
if !sawCancel {
|
||||
t.Fatal("expected the Mandrake to spend its negation on Ant's faint")
|
||||
}
|
||||
}
|
||||
|
||||
// Slime revives itself once and drops an Exposed on the enemy deck.
|
||||
func TestSlimeReviveOnce(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Slime")},
|
||||
[]Card{g.pet("A", 2), g.pet("B", 2), g.pet("C", 2)},
|
||||
)
|
||||
revives := 0
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == "Slime" {
|
||||
revives++
|
||||
}
|
||||
}
|
||||
if revives != 1 {
|
||||
t.Fatalf("Slime should revive exactly once, got %d", revives)
|
||||
}
|
||||
deckAilment := false
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
|
||||
deckAilment = true
|
||||
}
|
||||
}
|
||||
if !deckAilment {
|
||||
t.Fatal("Slime should drop an Exposed on the enemy deck")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shop foods & abilities ---
|
||||
|
||||
// Lucky Cat's Triple adds 3 apples to the hand.
|
||||
func TestLuckyCatTriple(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
// Three same-suit pets including Lucky Cat, so the Triple is legal.
|
||||
lc := g.unicornPet(t, "Lucky Cat") // red
|
||||
a := g.suitedPet("A", 1, SuitRed)
|
||||
b := g.suitedPet("B", 1, SuitRed)
|
||||
p1.Deck = []Card{lc, a, b}
|
||||
before := countFood(p1.Deck, FoodApple)
|
||||
if err := g.TradeStart(p1.ID, []string{lc.ID, a.ID, b.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := countFood(p1.Deck, FoodApple)
|
||||
if after != before+3 {
|
||||
t.Fatalf("Lucky Cat Triple should add 3 apples: before=%d after=%d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Water of Youth: buying it opens a sacrifice choice; resolving it discards the
|
||||
// chosen pet and the food, and grants a next-tier pet for free.
|
||||
func TestWaterOfYouthUpgrade(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
junk := g.suitedPet("Junk", 1, SuitRed)
|
||||
p1.Deck = []Card{junk}
|
||||
// Pin the top of the next tier's deck to a known pet with a Buy effect.
|
||||
g.ShopDecks[1] = append([]Card{g.unicornPet(t, "Thunderbird")}, g.ShopDecks[1]...)
|
||||
g.ShopRow[0] = g.unicornFood(t, "Water of Youth")
|
||||
if err := g.Buy(p1.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.PendingSacrifice == nil || g.PendingSacrifice.PlayerID != p1.ID {
|
||||
t.Fatal("buying Water of Youth should open a sacrifice choice")
|
||||
}
|
||||
if err := g.SacrificeChoose(p1.ID, junk.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p1.cardIndex(junk.ID) >= 0 {
|
||||
t.Fatal("the sacrificed pet should be gone")
|
||||
}
|
||||
for _, c := range p1.Deck {
|
||||
if c.Food == FoodWaterOfYouth {
|
||||
t.Fatal("Water of Youth food should be consumed")
|
||||
}
|
||||
}
|
||||
gotUpgrade := false
|
||||
for _, c := range p1.Deck {
|
||||
if c.IsPet() && c.Name == "Thunderbird" {
|
||||
gotUpgrade = true
|
||||
}
|
||||
}
|
||||
if !gotUpgrade {
|
||||
t.Fatalf("expected the Thunderbird upgrade from the next tier, deck=%+v", p1.Deck)
|
||||
}
|
||||
// The granted pet's Buy effect fires: Thunderbird grants 2 Mana.
|
||||
if p1.Mana != 2 {
|
||||
t.Fatalf("upgrade's Buy effect should fire (Thunderbird → 2 Mana), got %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Bigfoot lets its owner peek at the top of the shop deck, once per round.
|
||||
func TestBigfootPeek(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Deck = []Card{g.unicornPet(t, "Bigfoot")}
|
||||
if err := g.PeekShopDeck(p1.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p1.ShopPeek == nil {
|
||||
t.Fatal("peeking should reveal the top of the shop deck")
|
||||
}
|
||||
top := g.ShopDecks[g.Round-1][0]
|
||||
if p1.ShopPeek.ID != top.ID {
|
||||
t.Fatalf("peek should show the deck's top card %s, got %s", top.Name, p1.ShopPeek.Name)
|
||||
}
|
||||
if err := g.PeekShopDeck(p1.ID); err == nil {
|
||||
t.Fatal("Bigfoot should only peek once per round")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeekRequiresBigfoot(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Deck = []Card{g.pet("Plain", 2)}
|
||||
if err := g.PeekShopDeck(p1.ID); err == nil {
|
||||
t.Fatal("peeking without a Bigfoot should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tier 4-6 ---
|
||||
|
||||
func seat0ManaGains(res *BattleResult) int {
|
||||
n := 0
|
||||
for _, ev := range manaEvents(res) {
|
||||
if ev.Seat == 0 && ev.Count > 0 {
|
||||
n += ev.Count
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func seat0Summons(res *BattleResult, name string) int {
|
||||
n := 0
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Roc grants Mana on both Play (battle) and Sell (shop).
|
||||
func TestRocMana(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
// Sell grants shop-time mana.
|
||||
roc := g.unicornPet(t, "Roc")
|
||||
p1.Deck = []Card{roc, g.pet("Keep", 2)}
|
||||
if err := g.Sell(p1.ID, []string{roc.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p1.Mana != 1 {
|
||||
t.Fatalf("Roc Sell should grant 1 Mana, got %d", p1.Mana)
|
||||
}
|
||||
// Play grants battle-time mana too.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Roc")},
|
||||
[]Card{g.pet("Weak", 1)},
|
||||
)
|
||||
if seat0ManaGains(res) != 1 {
|
||||
t.Fatalf("Roc Play should grant 1 Mana, got %d", seat0ManaGains(res))
|
||||
}
|
||||
}
|
||||
|
||||
// Kraken spooks the enemy pet and drops a Spooked on the enemy deck.
|
||||
func TestKrakenSpooks(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Kraken")},
|
||||
[]Card{g.pet("A", 3), g.pet("B", 3)},
|
||||
)
|
||||
if len(eventsOfType(res, "ailment")) == 0 {
|
||||
t.Fatal("Kraken should spook the enemy pet")
|
||||
}
|
||||
deckAilment := false
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 1 && ev.Card != nil && ev.Card.IsAilment() {
|
||||
deckAilment = true
|
||||
}
|
||||
}
|
||||
if !deckAilment {
|
||||
t.Fatal("Kraken should drop a Spooked on the enemy deck")
|
||||
}
|
||||
}
|
||||
|
||||
// Unicorn's guard converts the next incoming friendly ailment into 2 apples.
|
||||
func TestUnicornAilmentGuard(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Unicorn"), g.pet("Tank", 10)},
|
||||
[]Card{g.pet("Sword", 4), g.unicornPet(t, "Nightcrawler")},
|
||||
)
|
||||
// Unicorn(4) trades with Sword(4) and faints, arming the guard. Nightcrawler
|
||||
// then tries to add 2 Spooked to the Tank; the guard turns the first into 2
|
||||
// apples on our deck (a release + apple summons), the second lands.
|
||||
released := false
|
||||
for _, ev := range eventsOfType(res, "release") {
|
||||
if ev.Card != nil && ev.Card.Name == "Unicorn" {
|
||||
released = true
|
||||
}
|
||||
}
|
||||
if !released {
|
||||
t.Fatal("Unicorn's guard should fire against the incoming Spooked")
|
||||
}
|
||||
if seat0Summons(res, "Apple") < 2 {
|
||||
t.Fatalf("the guard should add 2 apples on our deck, got %d", seat0Summons(res, "Apple"))
|
||||
}
|
||||
}
|
||||
|
||||
// Rootlin buffs friendly pets with base Power 2 or less.
|
||||
func TestRootlinSmallPetAura(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
// Rootlin faints to a 7-power Killer, arming +1 for small pets. A 2-power
|
||||
// Small then hits at 3, exactly killing the wounded Killer — a mutual KO
|
||||
// (draw). Without the aura the Small would deal 2 and lose outright.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Rootlin"), g.pet("Small", 2)},
|
||||
[]Card{g.pet("Killer", 7)},
|
||||
)
|
||||
if res.WinnerSeat == 1 {
|
||||
t.Fatal("Rootlin's aura should let the Small pet trade with the Killer (draw, not a loss)")
|
||||
}
|
||||
}
|
||||
|
||||
// Fairy recycles the next friendly faint to the bottom of the deck.
|
||||
func TestFairyRecyclesNextFaint(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Mana = 3
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Fairy"), g.tier1(t, "Ant")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
// Fairy(1) dies to Killer, spends 3 Mana, arms the guard. Ant then dies and
|
||||
// is sent to the bottom of our deck as a fresh copy.
|
||||
recycled := false
|
||||
for _, ev := range eventsOfType(res, "summon") {
|
||||
if ev.Seat == 0 && ev.Card != nil && ev.Card.Name == "Ant" {
|
||||
recycled = true
|
||||
}
|
||||
}
|
||||
if !recycled {
|
||||
t.Fatal("Fairy should recycle the Ant to the bottom of the deck")
|
||||
}
|
||||
if p1.Mana != 0 {
|
||||
t.Fatalf("Fairy should have spent 3 Mana, left %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Health Potion heals damage when its pet survives a hit.
|
||||
func TestHealthPotionHeals(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornFood(t, "Health Potion"), g.pet("Big", 10)},
|
||||
[]Card{g.pet("Chip", 3)},
|
||||
)
|
||||
if len(eventsOfType(res, "heal")) == 0 {
|
||||
t.Fatal("Health Potion should heal the pet after it is hurt")
|
||||
}
|
||||
if res.WinnerSeat != 0 {
|
||||
t.Fatalf("the healed 10-power pet should win, got %d", res.WinnerSeat)
|
||||
}
|
||||
}
|
||||
|
||||
// Kitsune gains Mana per friendly fainted pet at play time.
|
||||
func TestKitsuneManaPerFaint(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.pet("Weak", 1), g.unicornPet(t, "Kitsune"), g.pet("Big", 5)},
|
||||
[]Card{g.pet("Mid", 3)},
|
||||
)
|
||||
// Weak faints (1 friendly fainted), Kitsune enters and gains 1 Mana.
|
||||
if seat0ManaGains(res) != 1 {
|
||||
t.Fatalf("Kitsune should gain 1 Mana for the 1 fainted pet, got %d", seat0ManaGains(res))
|
||||
}
|
||||
}
|
||||
|
||||
// Werewolf eats 6 apples only on even rounds.
|
||||
func TestWerewolfEvenRound(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.Round = 2
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Werewolf"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Tank", 12)},
|
||||
)
|
||||
fed := false
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 && ev.Bonus >= 6 {
|
||||
fed = true
|
||||
}
|
||||
}
|
||||
if !fed {
|
||||
t.Fatal("Werewolf should eat 6 apples on an even round")
|
||||
}
|
||||
g2, _, _ := unicornGame(t)
|
||||
g2.Round = 1
|
||||
res2 := forceBattle(t, g2,
|
||||
[]Card{g2.unicornPet(t, "Werewolf"), g2.pet("Body", 3)},
|
||||
[]Card{g2.pet("Tank", 12)},
|
||||
)
|
||||
for _, ev := range eventsOfType(res2, "eat") {
|
||||
if ev.Seat == 0 {
|
||||
t.Fatal("Werewolf should not eat on an odd round")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loveland Frogman bounces the enemy pet to the bottom of the enemy deck.
|
||||
func TestLovelandBounce(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Loveland Frogman"), g.pet("Body", 5)},
|
||||
[]Card{g.pet("Enemy", 3), g.pet("Behind", 3)},
|
||||
)
|
||||
if len(eventsOfType(res, "bounce")) == 0 {
|
||||
t.Fatal("Loveland Frogman should bounce the enemy pet")
|
||||
}
|
||||
}
|
||||
|
||||
// Sleipnir's base Power equals the owner's Mana (capped).
|
||||
func TestSleipnirManaPower(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Mana = 5
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Sleipnir")},
|
||||
[]Card{g.pet("Four", 4)},
|
||||
)
|
||||
var reveal *BattleEvent
|
||||
for i, ev := range res.Events {
|
||||
if ev.Type == "reveal" && ev.Card != nil && ev.Card.Name == "Sleipnir" {
|
||||
reveal = &res.Events[i]
|
||||
}
|
||||
}
|
||||
if reveal == nil || reveal.Card.Power != 5 {
|
||||
t.Fatalf("Sleipnir should enter with Power 5 from 5 Mana, got %+v", reveal)
|
||||
}
|
||||
if res.WinnerSeat != 0 {
|
||||
t.Fatalf("the 5-power Sleipnir should beat the 4-power pet, got %d", res.WinnerSeat)
|
||||
}
|
||||
}
|
||||
|
||||
// Sea Serpent spends all Mana to throw that many Rocks.
|
||||
func TestSeaSerpentSpendManaRocks(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Mana = 3
|
||||
g.RollDie = func() int { return 2 }
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Sea Serpent")},
|
||||
[]Card{g.pet("Tank", 6)},
|
||||
)
|
||||
rocks := eventsOfType(res, "rock")
|
||||
if len(rocks) != 1 || rocks[0].Roll != 6 || !rocks[0].TargetDied {
|
||||
t.Fatalf("Sea Serpent should spend 3 Mana for 3 rocks (6 dmg): %+v", rocks)
|
||||
}
|
||||
if p1.Mana != 0 {
|
||||
t.Fatalf("Sea Serpent should spend all Mana, left %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Bakunawa spends all Mana to spook the enemy pet.
|
||||
func TestBakunawaSpendManaSpook(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
p1.Mana = 3
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Bakunawa"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Bruiser", 4)},
|
||||
)
|
||||
spook := 0
|
||||
for _, ev := range eventsOfType(res, "ailment") {
|
||||
if ev.Seat == 1 && ev.Card != nil && ev.Card.Ailment == AilmentSpooked {
|
||||
spook += ev.Count
|
||||
}
|
||||
}
|
||||
if spook != 3 {
|
||||
t.Fatalf("Bakunawa should add 3 Spooked, got %d", spook)
|
||||
}
|
||||
if p1.Mana != 0 {
|
||||
t.Fatalf("Bakunawa should spend all Mana, left %d", p1.Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Manticore boosts the value of Ailments on enemy pets by 1.
|
||||
func TestManticoreBoostsEnemyAilments(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
// Manticore faints to a 12-power BigKiller, arming +1 to enemy ailments.
|
||||
// Basilisk then exposes the BigKiller: its hit lands for 1 + 1(Exposed) +
|
||||
// 1(Manticore) = 3, so the wounded BigKiller reaches 6+3 = 9 damage.
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Manticore"), g.unicornPet(t, "Basilisk")},
|
||||
[]Card{g.pet("BigKiller", 12)},
|
||||
)
|
||||
got := 0
|
||||
for _, ev := range eventsOfType(res, "clash") {
|
||||
if len(ev.Damage) == 2 {
|
||||
got = ev.Damage[1]
|
||||
}
|
||||
}
|
||||
if got != 9 {
|
||||
t.Fatalf("Manticore-boosted Exposed should push BigKiller to 9 damage, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Behemoth eats 2 apples after every clash it survives (not once per battle).
|
||||
func TestBehemothEatsEveryClash(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Behemoth")},
|
||||
[]Card{g.pet("Chip1", 1), g.pet("Chip2", 1), g.pet("Chip3", 1)},
|
||||
)
|
||||
eats := 0
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 {
|
||||
eats++
|
||||
}
|
||||
}
|
||||
if eats < 2 {
|
||||
t.Fatalf("Behemoth should eat after each of several clashes, got %d eats", eats)
|
||||
}
|
||||
}
|
||||
|
||||
// Chimera spends Mana to summon random cards from the tier 1 discard pile.
|
||||
func TestChimeraSummonFromDiscard(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.Players[0].Mana = 4
|
||||
g.Discards = map[int][]Card{1: {g.pet("Discarded", 2)}}
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Chimera")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
if seat0Summons(res, "Discarded") != 2 {
|
||||
t.Fatalf("Chimera should summon 2 cards from the discard pile, got %d", seat0Summons(res, "Discarded"))
|
||||
}
|
||||
if g.Players[0].Mana != 0 {
|
||||
t.Fatalf("Chimera should spend 4 Mana, left %d", g.Players[0].Mana)
|
||||
}
|
||||
}
|
||||
|
||||
// Pixiu spends Mana to summon the top of the tier 6 shop deck.
|
||||
func TestPixiuSummonFromTierDeck(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.Players[0].Mana = 4
|
||||
g.ShopDecks[5] = append([]Card{g.pet("SixTop", 6)}, g.ShopDecks[5]...)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Pixiu")},
|
||||
[]Card{g.pet("Killer", 5)},
|
||||
)
|
||||
if seat0Summons(res, "SixTop") != 1 {
|
||||
t.Fatalf("Pixiu should summon the top tier 6 card, got %d", seat0Summons(res, "SixTop"))
|
||||
}
|
||||
}
|
||||
|
||||
// Vampire Bat throws rocks and eats apples equal to the damage dealt, when the
|
||||
// enemy pet is ailing.
|
||||
func TestVampireBatRockThenEat(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
g.RollDie = func() int { return 2 } // each rock deals 2
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornPet(t, "Barghest"), g.unicornPet(t, "Vampire Bat"), g.pet("Body", 3)},
|
||||
[]Card{g.pet("Tank", 20)},
|
||||
)
|
||||
// Barghest spooks the Tank (an ailment); Vampire Bat then throws 2 rocks (4
|
||||
// damage) and eats 4 apples.
|
||||
fed := false
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 && ev.Bonus >= 4 {
|
||||
fed = true
|
||||
}
|
||||
}
|
||||
if !fed {
|
||||
t.Fatal("Vampire Bat should eat apples equal to the rock damage dealt")
|
||||
}
|
||||
}
|
||||
|
||||
// Cornucopia (perk) feeds its pet 2 apples and adds 2 on top of the deck.
|
||||
func TestCornucopia(t *testing.T) {
|
||||
g, _, _ := unicornGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.unicornFood(t, "Cornucopia"), g.pet("Body", 4)},
|
||||
[]Card{g.pet("Weak", 1)},
|
||||
)
|
||||
fed := false
|
||||
for _, ev := range eventsOfType(res, "eat") {
|
||||
if ev.Seat == 0 && ev.Bonus >= 2 {
|
||||
fed = true
|
||||
}
|
||||
}
|
||||
if !fed {
|
||||
t.Fatal("Cornucopia should feed its pet 2 apples")
|
||||
}
|
||||
if seat0Summons(res, "Apple") < 2 {
|
||||
t.Fatalf("Cornucopia should add 2 apples on the deck, got %d", seat0Summons(res, "Apple"))
|
||||
}
|
||||
}
|
||||
|
||||
// Quetzalcoatl reveals a tier 3 or lower pet on buy for 3 apples.
|
||||
func TestQuetzalcoatlReveal(t *testing.T) {
|
||||
g, p1, _ := unicornGame(t)
|
||||
low := g.pet("Low", 2) // tier 1
|
||||
p1.Deck = []Card{low}
|
||||
g.ShopRow[0] = g.unicornPet(t, "Quetzalcoatl")
|
||||
if err := g.Buy(p1.ID, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.PendingReveal == nil || g.PendingReveal.PlayerID != p1.ID {
|
||||
t.Fatal("buying Quetzalcoatl should open a reveal")
|
||||
}
|
||||
before := countFood(p1.Deck, FoodApple)
|
||||
if err := g.RevealChoose(p1.ID, low.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countFood(p1.Deck, FoodApple) - before; got != 3 {
|
||||
t.Fatalf("Quetzalcoatl should grant a fixed 3 apples, got %d", got)
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -22,7 +22,13 @@ type PlayerView struct {
|
||||
BuysThisRound int `json:"buysThisRound,omitempty"`
|
||||
// Trumpets is the count banked for the next battle (Bird of Paradise).
|
||||
// Self-only — shown under the player's own deck.
|
||||
Trumpets int `json:"trumpets,omitempty"`
|
||||
Trumpets int `json:"trumpets,omitempty"`
|
||||
// Mana is the player's persistent Mana pool (Unicorn pack). Public: it's a
|
||||
// play-area counter derivable from public shop/battle events.
|
||||
Mana int `json:"mana,omitempty"`
|
||||
// ShopPeek is the card Bigfoot revealed off the top of the shop deck this
|
||||
// round (Unicorn pack). Self-only — it's private information.
|
||||
ShopPeek *Card `json:"shopPeek,omitempty"`
|
||||
Deck []Card `json:"deck,omitempty"` // self only
|
||||
}
|
||||
|
||||
@@ -56,7 +62,10 @@ type View struct {
|
||||
// see a reveal is in progress; the eligible options are only sent to the
|
||||
// buyer (they name the buyer's own hidden pets).
|
||||
PendingReveal *PendingReveal `json:"pendingReveal,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"`
|
||||
// PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the
|
||||
// options (the buyer's own pets) are only sent to the buyer.
|
||||
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"`
|
||||
WinnerSeat int `json:"winnerSeat"`
|
||||
// Log is the shared, public event log shown across every phase.
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
@@ -102,6 +111,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
DeckSize: len(p.Deck),
|
||||
PetCount: p.PetCount(),
|
||||
Avocados: p.Avocados,
|
||||
Mana: p.Mana,
|
||||
}
|
||||
if p.ID == playerID {
|
||||
v.YouSeat = p.Seat
|
||||
@@ -109,6 +119,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
pv.FirstBuyFree = p.FirstBuyFree
|
||||
pv.BuysThisRound = p.BuysThisRound
|
||||
pv.Trumpets = p.PendingTrumpets
|
||||
pv.ShopPeek = p.ShopPeek
|
||||
}
|
||||
v.Players = append(v.Players, pv)
|
||||
}
|
||||
@@ -126,6 +137,13 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
}
|
||||
v.PendingReveal = &reveal
|
||||
}
|
||||
if g.PendingSacrifice != nil {
|
||||
sac := *g.PendingSacrifice
|
||||
if sac.PlayerID != playerID {
|
||||
sac.Options = nil // hide which of the buyer's pets are eligible
|
||||
}
|
||||
v.PendingSacrifice = &sac
|
||||
}
|
||||
// Battle results (lineups, events) are public once resolved. Keep the
|
||||
// battle around during the following shop phase too, so late joiners /
|
||||
// reconnects can still see the last result.
|
||||
|
||||
@@ -161,6 +161,8 @@ func applyBotAction(g *game.Game, playerID string, a *ai.Action) error {
|
||||
return g.TradeChoose(playerID, a.Pick)
|
||||
case "revealChoose":
|
||||
return g.RevealChoose(playerID, a.CardID)
|
||||
case "sacrificeChoose":
|
||||
return g.SacrificeChoose(playerID, a.CardID)
|
||||
case "pass":
|
||||
return g.Pass(playerID)
|
||||
case "arrange":
|
||||
@@ -182,6 +184,9 @@ func botFallback(g *game.Game, playerID string) error {
|
||||
}
|
||||
switch g.Phase {
|
||||
case game.PhaseShop:
|
||||
if g.PendingSacrifice != nil && g.PendingSacrifice.PlayerID == playerID {
|
||||
return g.SacrificeChoose(playerID, g.PendingSacrifice.Options[0])
|
||||
}
|
||||
if g.PendingReveal != nil && g.PendingReveal.PlayerID == playerID {
|
||||
return g.RevealChoose(playerID, g.PendingReveal.Options[0])
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
||||
err = g.TradeChoose(c.playerID, msg.Pick)
|
||||
case "revealChoose":
|
||||
err = g.RevealChoose(c.playerID, msg.Card)
|
||||
case "sacrificeChoose":
|
||||
err = g.SacrificeChoose(c.playerID, msg.Card)
|
||||
case "peek":
|
||||
err = g.PeekShopDeck(c.playerID)
|
||||
case "pass":
|
||||
err = g.Pass(c.playerID)
|
||||
case "arrange":
|
||||
|
||||
Reference in New Issue
Block a user