Add pets for tiers 4-6.
This commit is contained in:
@@ -60,23 +60,34 @@ Six rounds, each with its own shop tier deck. Per round:
|
|||||||
its last-applied **perk** (e.g. Honey, Garlic).
|
its last-applied **perk** (e.g. Honey, Garlic).
|
||||||
4. **Battle** — automatic stack machine. Cards reveal off the top of each
|
4. **Battle** — automatic stack machine. Cards reveal off the top of each
|
||||||
deck until a pet is in play. Play effects fire on reveal (rocks roll a d6
|
deck until a pet is in play. Play effects fire on reveal (rocks roll a d6
|
||||||
with faces 0/0/1/1/2/2 and hit the opposing pet before the clash). The two
|
with faces 0/0/1/1/2/2 and hit the opposing pet before the clash; Skunk
|
||||||
pets simultaneously deal their full power to each other as damage markers;
|
strips foods; Wolverine steals apples; Chili mills the enemy deck). The
|
||||||
a pet with markers ≥ power faints (attack never drops while wounded).
|
two pets simultaneously deal their full power to each other as damage
|
||||||
Faint effects can push cards (apples, bees) onto either deck, recycle a
|
markers; a pet with markers ≥ power faints (attack never drops while
|
||||||
pet's apples (Dodo), or set aside delayed rocks that hit BOTH active pets
|
wounded). Faint effects push summons onto either deck, recycle apples
|
||||||
(Badger); survivors that took damage fire Hurt effects. Garlic prevents 1
|
(Dodo), set aside delayed/recurring/conditional rock volleys (Badger,
|
||||||
damage from every attack that hits its pet — a clash that damages no one
|
Blowfish, Snake, Crocodile), raise auras (Turkey, Mammoth), or arm a
|
||||||
ends the battle as a stalemate draw. Last player able to field a pet
|
team shield (Turtle). Survivors that took damage fire Hurt effects
|
||||||
wins: 1 trophy for rounds 1–5, 2 for round 6. Draws award nothing.
|
(Peacock, Camel, Gorilla). Shields (Turtle/Gorilla/Melon) block whole
|
||||||
|
hits, Garlic shaves 1 per attack, and Scorpion KOs anything its clash
|
||||||
|
attack manages to hurt. Hippo heals when enemies faint; Rhino rocks each
|
||||||
|
enemy pet as it's played. A clash that changes nothing ends the battle
|
||||||
|
as a stalemate draw. Last player able to field a pet wins: 1 trophy for
|
||||||
|
rounds 1–5, 2 for round 6. Draws award nothing.
|
||||||
|
|
||||||
Apples and bees are **temporary**: they leave your deck after the battle.
|
Apples and bees are **temporary**: they leave your deck after the battle.
|
||||||
Most trophies after round 6 wins.
|
Most trophies after round 6 wins.
|
||||||
|
|
||||||
Tiers 1–3 use real card data (T1: Ant, Cricket, Duck, Otter, Mosquito, Fish;
|
All six tiers use the real card data:
|
||||||
T2: Worm, Flamingo, Peacock, Swan, Rat, Spider + Honey; T3: Dog, Dolphin,
|
|
||||||
Giraffe, Camel, Sheep, Dodo, Badger + Garlic). Tiers 4–6 are effect-less
|
| Tier | Pets | Food |
|
||||||
placeholders awaiting their real definitions.
|
| ---- | ---- | ---- |
|
||||||
|
| 1 | Ant, Cricket, Duck, Otter, Mosquito, Fish | — |
|
||||||
|
| 2 | Worm, Flamingo, Peacock, Swan, Rat, Spider | Honey |
|
||||||
|
| 3 | Dog, Dolphin, Giraffe, Camel, Sheep, Dodo, Badger | Garlic |
|
||||||
|
| 4 | Squirrel, Turtle, Rooster, Bison, Blowfish, Skunk, Hippo | Pineapple |
|
||||||
|
| 5 | Monkey, Rhino, Crocodile, Scorpion, Seal, Shark, Turkey | Chili |
|
||||||
|
| 6 | Gorilla, Fly, Leopard, Mammoth, Cat, Snake, Wolverine | Melon |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
+363
-92
@@ -5,8 +5,9 @@ package game
|
|||||||
type BattleUnit struct {
|
type BattleUnit struct {
|
||||||
Card Card `json:"card"`
|
Card Card `json:"card"`
|
||||||
Foods []Card `json:"foods,omitempty"`
|
Foods []Card `json:"foods,omitempty"`
|
||||||
Bonus int `json:"bonus"` // total power added by foods (and eating)
|
Bonus int `json:"bonus"` // total power added by foods, eating, auras
|
||||||
Damage int `json:"damage"` // damage markers accumulated this battle
|
Damage int `json:"damage"` // damage markers accumulated this battle
|
||||||
|
Shield int `json:"shield"` // hits that will be fully prevented (Gorilla, Melon)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||||
@@ -43,15 +44,25 @@ func (u *BattleUnit) prevention() int {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeHit applies one attack of the given strength and returns the damage
|
// hasKnockout reports whether the unit's clash attacks KO (Scorpion).
|
||||||
// actually dealt after prevention.
|
func (u *BattleUnit) hasKnockout() bool {
|
||||||
func (u *BattleUnit) takeHit(amount int) int {
|
for _, e := range u.effects() {
|
||||||
if amount <= 0 {
|
if e.Trigger == TriggerPassive && e.Action == ActionKnockout {
|
||||||
return 0
|
return true
|
||||||
}
|
}
|
||||||
dealt := max(0, amount-u.prevention())
|
}
|
||||||
u.Damage += dealt
|
return false
|
||||||
return dealt
|
}
|
||||||
|
|
||||||
|
// appleCount counts apples among the given foods.
|
||||||
|
func appleCount(foods []Card) int {
|
||||||
|
n := 0
|
||||||
|
for _, f := range foods {
|
||||||
|
if f.Food == FoodApple {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBee reports whether a card is a summoned Bee.
|
// isBee reports whether a card is a summoned Bee.
|
||||||
@@ -59,19 +70,26 @@ func isBee(c Card) bool { return c.IsPet() && c.Name == "Bee" }
|
|||||||
|
|
||||||
// BattleEvent is one step of the battle, in order, for clients to animate.
|
// BattleEvent is one step of the battle, in order, for clients to animate.
|
||||||
type BattleEvent struct {
|
type BattleEvent struct {
|
||||||
|
// "prep": Card starts the battle already in play for Seat (Monkey).
|
||||||
// "reveal": Seat flipped Card off their stack (food or pet).
|
// "reveal": Seat flipped Card off their stack (food or pet).
|
||||||
// "summon": an effect put Card on top of Seat's stack.
|
// "summon": an effect put Card on top of Seat's stack.
|
||||||
|
// "mill": Card was discarded off the top of Seat's stack (Chili).
|
||||||
// "rock": Seat's pet threw rocks at Target's pet in play.
|
// "rock": Seat's pet threw rocks at Target's pet in play.
|
||||||
// "clash": the pets in play traded blows.
|
// "clash": the pets in play traded blows.
|
||||||
|
// "shield": Seat's pet blocked a hit entirely.
|
||||||
|
// "strip": Seat's pet discarded all of Target's pet's foods (Skunk).
|
||||||
|
// "steal": Seat's pet stole Count apples from Target's pet (Wolverine).
|
||||||
// "eat": Seat's pet ate apples; Bonus is its new total.
|
// "eat": Seat's pet ate apples; Bonus is its new total.
|
||||||
|
// "heal": Seat's pet healed; DamageAfter is its new damage total.
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Seat int `json:"seat,omitempty"`
|
Seat int `json:"seat,omitempty"`
|
||||||
Target int `json:"target,omitempty"`
|
Target int `json:"target,omitempty"`
|
||||||
Card *Card `json:"card,omitempty"`
|
Card *Card `json:"card,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
// clash: per-seat damage totals / deaths after the exchange.
|
// clash: per-seat damage totals / deaths after the exchange.
|
||||||
Damage []int `json:"damage,omitempty"`
|
Damage []int `json:"damage,omitempty"`
|
||||||
Died []bool `json:"died,omitempty"`
|
Died []bool `json:"died,omitempty"`
|
||||||
// rock: dice total rolled and the target pet's resulting state.
|
// rock: dice total rolled; rock/strip/steal: target pet's fate.
|
||||||
Roll int `json:"roll"`
|
Roll int `json:"roll"`
|
||||||
DamageAfter int `json:"damageAfter,omitempty"`
|
DamageAfter int `json:"damageAfter,omitempty"`
|
||||||
TargetDied bool `json:"targetDied,omitempty"`
|
TargetDied bool `json:"targetDied,omitempty"`
|
||||||
@@ -90,21 +108,64 @@ type BattleResult struct {
|
|||||||
Trophies int `json:"trophies"` // awarded to the winner
|
Trophies int `json:"trophies"` // awarded to the winner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setAsideRocks is a fainted pet's pending rock payout.
|
||||||
|
type setAsideRocks struct {
|
||||||
|
dice int
|
||||||
|
everyone bool // Badger: hits every active pet, the owner's own included
|
||||||
|
}
|
||||||
|
|
||||||
// battleSide is one seat's live state during the simulation.
|
// battleSide is one seat's live state during the simulation.
|
||||||
type battleSide struct {
|
type battleSide struct {
|
||||||
stack []Card // remaining face-down cards, top first
|
stack []Card // remaining face-down cards, top first
|
||||||
pending []Card // foods revealed and waiting for a pet
|
pending []Card // foods revealed (or prepped) waiting for a pet
|
||||||
unit *BattleUnit
|
unit *BattleUnit
|
||||||
beesFainted int // friendly bees fainted so far (Dog)
|
beesFainted int // friendly bees fainted so far (Dog)
|
||||||
delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays
|
petsFainted int // friendly pets fainted so far (Shark)
|
||||||
|
shields int // Turtle charges: next friendly hit fully prevented
|
||||||
|
beeBonus int // Turkey aura: +power for later friendly bees
|
||||||
|
petBonus int // Mammoth aura: +power for later friendly pets
|
||||||
|
|
||||||
|
oneShotRocks []setAsideRocks // Badger/Blowfish: on next own pet play
|
||||||
|
recurringRocks []int // Snake: on every own pet play
|
||||||
|
lastPetRocks []int // Crocodile: when the enemy plays their last pet
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPetInStack reports whether any pet remains face-down in the stack.
|
||||||
|
func (s *battleSide) hasPetInStack() bool {
|
||||||
|
for _, c := range s.stack {
|
||||||
|
if c.IsPet() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
||||||
type queuedPlay struct {
|
type queuedPlay struct {
|
||||||
seat int
|
seat int
|
||||||
unit *BattleUnit // the pet whose play queued this (must still be in play for eats)
|
unit *BattleUnit // the unit whose play queued this; nil for set-asides
|
||||||
effect Effect
|
effect Effect
|
||||||
badger bool // delayedRocks payout: hits EVERY active pet, own included
|
everyone bool // rock volley hits every active pet (Badger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectCount resolves an effect's final count: base × Per statistic,
|
||||||
|
// limited by Cap.
|
||||||
|
func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
||||||
|
n := e.count()
|
||||||
|
switch e.Per {
|
||||||
|
case PerFaintedBees:
|
||||||
|
n *= s.beesFainted
|
||||||
|
case PerFaintedPets:
|
||||||
|
n *= s.petsFainted
|
||||||
|
case PerEatenApples:
|
||||||
|
n *= appleCount(u.Foods)
|
||||||
|
case PerPower:
|
||||||
|
n *= u.Power()
|
||||||
|
}
|
||||||
|
if e.Cap > 0 && n > e.Cap {
|
||||||
|
n = e.Cap
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBattle simulates the battle from the players' arranged decks,
|
// resolveBattle simulates the battle from the players' arranged decks,
|
||||||
@@ -113,37 +174,100 @@ type queuedPlay struct {
|
|||||||
// The battle is a stack machine: each side reveals cards off the top of
|
// The battle is a stack machine: each side reveals cards off the top of
|
||||||
// their deck until a pet is in play (foods along the way attach to it; only
|
// their deck until a pet is in play (foods along the way attach to it; only
|
||||||
// the last-applied perk counts). If anyone can no longer field a pet the
|
// the last-applied perk counts). If anyone can no longer field a pet the
|
||||||
// battle ends. Otherwise play effects resolve — rocks roll dice (faces
|
// battle ends. Otherwise play effects resolve (rocks, strips, steals,
|
||||||
// 0,0,1,1,2,2) and can faint a pet before the clash. Then the two pets deal
|
// mills — any of which can faint a pet before the clash). Then the two pets
|
||||||
// their full Power to each other simultaneously as damage markers; a pet
|
// deal their full Power to each other simultaneously as damage markers; a
|
||||||
// with Damage >= Power faints, firing Faint effects. Survivors that took
|
// pet with Damage >= Power faints, firing Faint effects. Survivors that
|
||||||
// damage fire Hurt effects. Garlic prevents 1 damage from every attack that
|
// took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block
|
||||||
// hits its pet; a clash that deals no damage to anyone ends the battle as a
|
// entire hits; Garlic shaves 1 from each; Scorpion KOs whatever its clash
|
||||||
|
// attack manages to hurt. A clash that changes nothing ends the battle as a
|
||||||
// stalemate.
|
// stalemate.
|
||||||
func (g *Game) resolveBattle() {
|
func (g *Game) resolveBattle() {
|
||||||
n := len(g.Players)
|
n := len(g.Players)
|
||||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)}
|
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)}
|
||||||
sides := make([]*battleSide, n)
|
sides := make([]*battleSide, n)
|
||||||
|
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
||||||
|
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)}
|
s := &battleSide{stack: append([]Card(nil), p.Deck...)}
|
||||||
|
sides[p.Seat] = s
|
||||||
res.StackSizes[p.Seat] = len(p.Deck)
|
res.StackSizes[p.Seat] = len(p.Deck)
|
||||||
}
|
}
|
||||||
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||||
|
// to the owner's first pet.
|
||||||
|
for _, p := range g.Players {
|
||||||
|
for _, c := range p.Deck {
|
||||||
|
for _, e := range c.Effects {
|
||||||
|
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||||||
|
for range e.count() {
|
||||||
|
apple := g.newApple()
|
||||||
|
sides[p.Seat].pending = append(sides[p.Seat].pending, apple)
|
||||||
|
emit(BattleEvent{Type: "prep", Seat: p.Seat, Card: &apple})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
summon := func(seat int, c Card) {
|
summon := func(seat int, c Card) {
|
||||||
s := sides[seat]
|
s := sides[seat]
|
||||||
s.stack = append([]Card{c}, s.stack...)
|
s.stack = append([]Card{c}, s.stack...)
|
||||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c})
|
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c})
|
||||||
}
|
}
|
||||||
|
mintFor := func(kind string) Card {
|
||||||
|
if kind == "bee" {
|
||||||
|
return g.newBee()
|
||||||
|
}
|
||||||
|
return g.newApple()
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed gates battle-time effects on their conditions.
|
||||||
|
allowed := func(e Effect, u *BattleUnit) bool {
|
||||||
|
if g.Round < e.MinRound {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch e.Condition {
|
||||||
|
case ConditionHasPerk:
|
||||||
|
return u != nil && u.activePerk() != nil
|
||||||
|
case ConditionTripled:
|
||||||
|
return false // shop-time condition; never true in battle
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hitUnit applies one attack against a seat's pet: shields block the
|
||||||
|
// whole hit, Garlic shaves per-attack damage. Returns damage dealt and
|
||||||
|
// whether a shield blocked it.
|
||||||
|
hitUnit := func(seat, amount int) (dealt int, blocked bool) {
|
||||||
|
u := sides[seat].unit
|
||||||
|
if u == nil || amount <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if u.Shield > 0 {
|
||||||
|
u.Shield--
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
if sides[seat].shields > 0 {
|
||||||
|
sides[seat].shields--
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
dealt = max(0, amount-u.prevention())
|
||||||
|
u.Damage += dealt
|
||||||
|
return dealt, false
|
||||||
|
}
|
||||||
|
|
||||||
// faint fires the unit's faint effects (its own and its perk's) in
|
// faint fires the unit's faint effects (its own and its perk's) in
|
||||||
// effect order, and tracks fainted bees.
|
// effect order, updates faint counters, and notifies enemy pets
|
||||||
faint := func(seat int, u *BattleUnit) {
|
// (Hippo's heal).
|
||||||
|
var faint func(seat int, u *BattleUnit)
|
||||||
|
faint = func(seat int, u *BattleUnit) {
|
||||||
|
s := sides[seat]
|
||||||
|
s.petsFainted++
|
||||||
if isBee(u.Card) {
|
if isBee(u.Card) {
|
||||||
sides[seat].beesFainted++
|
s.beesFainted++
|
||||||
}
|
}
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger != TriggerFaint {
|
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
@@ -152,12 +276,8 @@ func (g *Game) resolveBattle() {
|
|||||||
if e.Target == "enemy" {
|
if e.Target == "enemy" {
|
||||||
target = (seat + 1) % n
|
target = (seat + 1) % n
|
||||||
}
|
}
|
||||||
for range e.count() {
|
for range effectCount(e, s, u) {
|
||||||
if e.Card == "bee" {
|
summon(target, mintFor(e.Card))
|
||||||
summon(target, g.newBee())
|
|
||||||
} else {
|
|
||||||
summon(target, g.newApple())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case ActionRecycleApples:
|
case ActionRecycleApples:
|
||||||
recycled := 0
|
recycled := 0
|
||||||
@@ -168,7 +288,34 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ActionDelayedRocks:
|
case ActionDelayedRocks:
|
||||||
sides[seat].delayedRocks = append(sides[seat].delayedRocks, e.count())
|
s.oneShotRocks = append(s.oneShotRocks,
|
||||||
|
setAsideRocks{dice: e.count(), everyone: e.Target == "all"})
|
||||||
|
case ActionRecurringRocks:
|
||||||
|
s.recurringRocks = append(s.recurringRocks, e.count())
|
||||||
|
case ActionEnemyLastPetRocks:
|
||||||
|
s.lastPetRocks = append(s.lastPetRocks, e.count())
|
||||||
|
case ActionShieldNext:
|
||||||
|
s.shields += e.count()
|
||||||
|
case ActionBeeAura:
|
||||||
|
s.beeBonus += e.count()
|
||||||
|
case ActionPetAura:
|
||||||
|
s.petBonus += e.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
|
||||||
|
for other, os := range sides {
|
||||||
|
if other == seat || os.unit == nil || !os.unit.Alive() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range os.unit.effects() {
|
||||||
|
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
healed := min(effectCount(e, os, os.unit), os.unit.Damage)
|
||||||
|
if healed > 0 {
|
||||||
|
os.unit.Damage -= healed
|
||||||
|
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,30 +326,28 @@ func (g *Game) resolveBattle() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger != TriggerHurt {
|
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
case ActionEatApple:
|
case ActionEatApple:
|
||||||
for range e.count() {
|
for range effectCount(e, sides[seat], u) {
|
||||||
u.Foods = append(u.Foods, g.newApple())
|
u.Foods = append(u.Foods, g.newApple())
|
||||||
u.Bonus++
|
u.Bonus++
|
||||||
}
|
}
|
||||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus})
|
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus})
|
||||||
case ActionSummonTop:
|
case ActionSummonTop:
|
||||||
for range e.count() {
|
for range effectCount(e, sides[seat], u) {
|
||||||
if e.Card == "bee" {
|
summon(seat, mintFor(e.Card))
|
||||||
summon(seat, g.newBee())
|
|
||||||
} else {
|
|
||||||
summon(seat, g.newApple())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case ActionShieldSelf:
|
||||||
|
u.Shield += e.count()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// throwRocks rolls `dice` rock dice against one target's pet, handling
|
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
|
||||||
// prevention, death (with faint), and hurt. Reports whether it killed.
|
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
||||||
throwRocks := func(from, target, dice int) (killed bool) {
|
throwRocks := func(from, target, dice int) (killed bool) {
|
||||||
tu := sides[target].unit
|
tu := sides[target].unit
|
||||||
if tu == nil {
|
if tu == nil {
|
||||||
@@ -212,12 +357,15 @@ func (g *Game) resolveBattle() {
|
|||||||
for range dice {
|
for range dice {
|
||||||
roll += g.rollRockDie()
|
roll += g.rollRockDie()
|
||||||
}
|
}
|
||||||
dealt := tu.takeHit(roll)
|
dealt, blocked := hitUnit(target, roll)
|
||||||
died := !tu.Alive()
|
died := !tu.Alive()
|
||||||
emit(BattleEvent{
|
emit(BattleEvent{
|
||||||
Type: "rock", Seat: from, Target: target, Roll: roll,
|
Type: "rock", Seat: from, Target: target, Roll: roll,
|
||||||
DamageAfter: tu.Damage, TargetDied: died,
|
DamageAfter: tu.Damage, TargetDied: died,
|
||||||
})
|
})
|
||||||
|
if blocked {
|
||||||
|
emit(BattleEvent{Type: "shield", Seat: target})
|
||||||
|
}
|
||||||
if died {
|
if died {
|
||||||
faint(target, tu)
|
faint(target, tu)
|
||||||
sides[target].unit = nil
|
sides[target].unit = nil
|
||||||
@@ -229,43 +377,97 @@ func (g *Game) resolveBattle() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextTarget finds the seat whose pet a standard enemy-directed play
|
||||||
|
// effect hits.
|
||||||
|
nextTarget := func(from int) int {
|
||||||
|
for off := 1; off < n; off++ {
|
||||||
|
cand := (from + off) % n
|
||||||
|
if sides[cand].unit != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
// The exchange loop terminates: every clash kills at least one pet or
|
// The exchange loop terminates: every clash kills at least one pet or
|
||||||
// is a detected stalemate, and summons are finite. The guard is just
|
// is a detected stalemate, and summons/auras are finite. The guard is
|
||||||
// insurance as effects get richer.
|
// just insurance as effects get richer.
|
||||||
for range 10_000 {
|
for range 10_000 {
|
||||||
// Reveal until every side has a pet in play or runs out. Play
|
// Reveal until every side has a pet in play or runs out. Play
|
||||||
// effects queue up and resolve after all reveals (simultaneous).
|
// effects queue up and resolve after all reveals (simultaneous).
|
||||||
var plays []queuedPlay
|
var plays []queuedPlay
|
||||||
|
newlyPlayed := make([]bool, n)
|
||||||
for seat, s := range sides {
|
for seat, s := range sides {
|
||||||
for s.unit == nil && len(s.stack) > 0 {
|
for s.unit == nil && len(s.stack) > 0 {
|
||||||
c := s.stack[0]
|
c := s.stack[0]
|
||||||
s.stack = s.stack[1:]
|
s.stack = s.stack[1:]
|
||||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c})
|
|
||||||
if c.IsFood() {
|
if c.IsFood() {
|
||||||
|
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c})
|
||||||
s.pending = append(s.pending, c)
|
s.pending = append(s.pending, c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u := &BattleUnit{Card: c, Foods: s.pending}
|
u := &BattleUnit{Card: c, Foods: s.pending}
|
||||||
for _, f := range s.pending {
|
u.Bonus += appleCount(s.pending)
|
||||||
if f.Food == FoodApple {
|
u.Bonus += s.petBonus
|
||||||
u.Bonus++
|
if isBee(c) {
|
||||||
}
|
u.Bonus += s.beeBonus
|
||||||
}
|
}
|
||||||
|
// Pets carry their starting bonus (foods + auras) in the
|
||||||
|
// reveal event so clients can display it directly.
|
||||||
|
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus})
|
||||||
s.pending = nil
|
s.pending = nil
|
||||||
s.unit = u
|
s.unit = u
|
||||||
// Set-aside Badgers pay out when the next pet plays,
|
newlyPlayed[seat] = true
|
||||||
// before that pet's own play effects.
|
// Set-aside payouts fire before the new pet's own play
|
||||||
for _, dice := range s.delayedRocks {
|
// effects.
|
||||||
plays = append(plays, queuedPlay{seat: seat, unit: u, badger: true,
|
for _, r := range s.oneShotRocks {
|
||||||
|
plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone,
|
||||||
|
effect: Effect{Action: ActionThrowRock, Count: r.dice}})
|
||||||
|
}
|
||||||
|
s.oneShotRocks = nil
|
||||||
|
for _, dice := range s.recurringRocks {
|
||||||
|
plays = append(plays, queuedPlay{seat: seat,
|
||||||
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
||||||
}
|
}
|
||||||
s.delayedRocks = nil
|
|
||||||
for _, e := range u.effects() {
|
for _, e := range u.effects() {
|
||||||
if e.Trigger == TriggerPlay {
|
if e.Trigger != TriggerPlay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Shields apply the instant the pet enters play, ahead
|
||||||
|
// of any queued rocks (Melon).
|
||||||
|
if e.Action == ActionShieldSelf {
|
||||||
|
u.Shield += e.count()
|
||||||
|
continue
|
||||||
|
}
|
||||||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||||||
|
// Crocodile volleys when the enemy plays their last pet.
|
||||||
|
for seat := range sides {
|
||||||
|
if !newlyPlayed[seat] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for other, os := range sides {
|
||||||
|
if other == seat {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if os.unit != nil {
|
||||||
|
for _, e := range os.unit.effects() {
|
||||||
|
if e.Trigger == TriggerEnemyPlay && e.Action == ActionThrowRock && allowed(e, os.unit) {
|
||||||
|
plays = append(plays, queuedPlay{seat: other, unit: os.unit, effect: e})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sides[seat].hasPetInStack() {
|
||||||
|
for _, dice := range os.lastPetRocks {
|
||||||
|
plays = append(plays, queuedPlay{seat: other,
|
||||||
|
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
||||||
|
}
|
||||||
|
os.lastPetRocks = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battle over? A side that couldn't field a pet is out; no further
|
// Battle over? A side that couldn't field a pet is out; no further
|
||||||
@@ -280,41 +482,96 @@ func (g *Game) resolveBattle() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve play effects. Rocks land before the clash and can faint
|
// Resolve play effects. Any of these can faint a pet before the
|
||||||
// pets outright.
|
// clash.
|
||||||
rockDeath := false
|
anyDeath := false
|
||||||
for _, q := range plays {
|
for _, q := range plays {
|
||||||
switch q.effect.Action {
|
// Effects sourced from a specific unit fizzle if it's gone.
|
||||||
case ActionThrowRock:
|
if q.unit != nil && sides[q.seat].unit != q.unit {
|
||||||
if q.badger {
|
|
||||||
// At EACH active pet, the owner's own included.
|
|
||||||
for seat := range sides {
|
|
||||||
if throwRocks(q.seat, seat, q.effect.count()) {
|
|
||||||
rockDeath = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
target := -1
|
|
||||||
for off := 1; off < n; off++ {
|
|
||||||
cand := (q.seat + off) % n
|
|
||||||
if sides[cand].unit != nil {
|
|
||||||
target = cand
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target >= 0 && throwRocks(q.seat, target, q.effect.count()) {
|
|
||||||
rockDeath = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case ActionEatApple:
|
|
||||||
// Skip if the eater already died (e.g. to Badger rocks).
|
|
||||||
if sides[q.seat].unit != q.unit {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
count := q.effect.count()
|
if q.unit != nil && !allowed(q.effect, q.unit) {
|
||||||
if q.effect.Per == PerFaintedBees {
|
continue
|
||||||
count *= sides[q.seat].beesFainted
|
|
||||||
}
|
}
|
||||||
|
switch q.effect.Action {
|
||||||
|
case ActionThrowRock:
|
||||||
|
dice := q.effect.count()
|
||||||
|
if q.unit != nil {
|
||||||
|
dice = effectCount(q.effect, sides[q.seat], q.unit)
|
||||||
|
}
|
||||||
|
if q.everyone {
|
||||||
|
for seat := range sides {
|
||||||
|
if throwRocks(q.seat, seat, dice) {
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if t := nextTarget(q.seat); t >= 0 {
|
||||||
|
if throwRocks(q.seat, t, dice) {
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ActionStripFoods:
|
||||||
|
t := nextTarget(q.seat)
|
||||||
|
if t < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tu := sides[t].unit
|
||||||
|
tu.Bonus -= appleCount(tu.Foods)
|
||||||
|
tu.Foods = nil
|
||||||
|
died := !tu.Alive()
|
||||||
|
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died})
|
||||||
|
if died {
|
||||||
|
faint(t, tu)
|
||||||
|
sides[t].unit = nil
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
case ActionStealApples:
|
||||||
|
t := nextTarget(q.seat)
|
||||||
|
if t < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tu := sides[t].unit
|
||||||
|
steal := min(q.effect.count(), appleCount(tu.Foods))
|
||||||
|
if steal == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
moved := 0
|
||||||
|
kept := tu.Foods[:0]
|
||||||
|
for _, f := range tu.Foods {
|
||||||
|
if f.Food == FoodApple && moved < steal {
|
||||||
|
q.unit.Foods = append(q.unit.Foods, f)
|
||||||
|
moved++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
tu.Foods = kept
|
||||||
|
tu.Bonus -= moved
|
||||||
|
q.unit.Bonus += moved
|
||||||
|
died := !tu.Alive()
|
||||||
|
emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died})
|
||||||
|
if died {
|
||||||
|
faint(t, tu)
|
||||||
|
sides[t].unit = nil
|
||||||
|
anyDeath = true
|
||||||
|
}
|
||||||
|
case ActionMillEnemy:
|
||||||
|
t := (q.seat + 1) % n
|
||||||
|
ts := sides[t]
|
||||||
|
for len(ts.stack) > 0 {
|
||||||
|
top := ts.stack[0]
|
||||||
|
if top.IsPet() && !isBee(top) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ts.stack = ts.stack[1:]
|
||||||
|
emit(BattleEvent{Type: "mill", Seat: t, Card: &top})
|
||||||
|
}
|
||||||
|
case ActionSummonTop:
|
||||||
|
for range effectCount(q.effect, sides[q.seat], q.unit) {
|
||||||
|
summon(q.seat, mintFor(q.effect.Card))
|
||||||
|
}
|
||||||
|
case ActionEatApple:
|
||||||
|
count := effectCount(q.effect, sides[q.seat], q.unit)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
for range count {
|
for range count {
|
||||||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||||||
@@ -324,21 +581,35 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rockDeath {
|
if anyDeath {
|
||||||
continue // refill before any clash
|
continue // refill before any clash
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clash. Two-player for now; >2-player battle pairings come later
|
// Clash. Two-player for now; >2-player battle pairings come later
|
||||||
// (the surrounding state is already per-seat).
|
// (the surrounding state is already per-seat).
|
||||||
ua, ub := sides[0].unit, sides[1].unit
|
ua, ub := sides[0].unit, sides[1].unit
|
||||||
dealtA := ua.takeHit(ub.Power())
|
powA, powB := ua.Power(), ub.Power()
|
||||||
dealtB := ub.takeHit(ua.Power())
|
dealtA, blockedA := hitUnit(0, powB)
|
||||||
|
dealtB, blockedB := hitUnit(1, powA)
|
||||||
|
// Scorpion: a clash attack that hurts, KOs.
|
||||||
|
if dealtA > 0 && ub.hasKnockout() {
|
||||||
|
ua.Damage = max(ua.Damage, ua.Power())
|
||||||
|
}
|
||||||
|
if dealtB > 0 && ua.hasKnockout() {
|
||||||
|
ub.Damage = max(ub.Damage, ub.Power())
|
||||||
|
}
|
||||||
emit(BattleEvent{
|
emit(BattleEvent{
|
||||||
Type: "clash",
|
Type: "clash",
|
||||||
Damage: []int{ua.Damage, ub.Damage},
|
Damage: []int{ua.Damage, ub.Damage},
|
||||||
Died: []bool{!ua.Alive(), !ub.Alive()},
|
Died: []bool{!ua.Alive(), !ub.Alive()},
|
||||||
})
|
})
|
||||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 {
|
if blockedA {
|
||||||
|
emit(BattleEvent{Type: "shield", Seat: 0})
|
||||||
|
}
|
||||||
|
if blockedB {
|
||||||
|
emit(BattleEvent{Type: "shield", Seat: 1})
|
||||||
|
}
|
||||||
|
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB {
|
||||||
break // stalemate: nothing can ever change
|
break // stalemate: nothing can ever change
|
||||||
}
|
}
|
||||||
dealt := []int{dealtA, dealtB}
|
dealt := []int{dealtA, dealtB}
|
||||||
|
|||||||
+206
-33
@@ -26,6 +26,9 @@ const (
|
|||||||
FoodApple = "apple"
|
FoodApple = "apple"
|
||||||
FoodHoney = "honey"
|
FoodHoney = "honey"
|
||||||
FoodGarlic = "garlic"
|
FoodGarlic = "garlic"
|
||||||
|
FoodPineapple = "pineapple"
|
||||||
|
FoodChili = "chili"
|
||||||
|
FoodMelon = "melon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EffectTrigger is when an effect fires.
|
// EffectTrigger is when an effect fires.
|
||||||
@@ -41,6 +44,10 @@ const (
|
|||||||
// TriggerBattlePrep fires right after the shop phase ends, as players
|
// TriggerBattlePrep fires right after the shop phase ends, as players
|
||||||
// begin ordering their decks.
|
// begin ordering their decks.
|
||||||
TriggerBattlePrep EffectTrigger = "battlePrep"
|
TriggerBattlePrep EffectTrigger = "battlePrep"
|
||||||
|
// TriggerEnemyFaint fires on a pet in play whenever an enemy pet faints.
|
||||||
|
TriggerEnemyFaint EffectTrigger = "enemyFaint"
|
||||||
|
// TriggerEnemyPlay fires on a pet in play whenever the enemy plays a pet.
|
||||||
|
TriggerEnemyPlay EffectTrigger = "enemyPlay"
|
||||||
// TriggerPassive marks always-on effects (e.g. Garlic's damage
|
// TriggerPassive marks always-on effects (e.g. Garlic's damage
|
||||||
// prevention); they're consulted contextually rather than fired.
|
// prevention); they're consulted contextually rather than fired.
|
||||||
TriggerPassive EffectTrigger = "passive"
|
TriggerPassive EffectTrigger = "passive"
|
||||||
@@ -73,13 +80,65 @@ const (
|
|||||||
// apples back on top of its owner's deck.
|
// apples back on top of its owner's deck.
|
||||||
ActionRecycleApples EffectAction = "recycleApples"
|
ActionRecycleApples EffectAction = "recycleApples"
|
||||||
// ActionDelayedRocks (faint) sets the pet aside: when its owner plays
|
// ActionDelayedRocks (faint) sets the pet aside: when its owner plays
|
||||||
// their next pet, it throws Count rocks at EACH active pet — the
|
// their next pet, it throws Count rocks. With Target "all" (Badger)
|
||||||
// enemy's and the owner's own.
|
// the volley hits EACH active pet — the owner's own included;
|
||||||
|
// otherwise it targets the enemy pet as usual (Blowfish).
|
||||||
ActionDelayedRocks EffectAction = "delayedRocks"
|
ActionDelayedRocks EffectAction = "delayedRocks"
|
||||||
|
// ActionRecurringRocks (faint) sets the pet aside: EVERY time its owner
|
||||||
|
// plays a pet, it throws Count rocks at the enemy pet (Snake).
|
||||||
|
ActionRecurringRocks EffectAction = "recurringRocks"
|
||||||
|
// ActionEnemyLastPetRocks (faint) sets the pet aside: when the enemy
|
||||||
|
// plays the last pet in their deck, it throws Count rocks (Crocodile).
|
||||||
|
ActionEnemyLastPetRocks EffectAction = "enemyLastPetRocks"
|
||||||
|
// ActionShieldNext (faint) sets the pet aside: the next Count times a
|
||||||
|
// friendly pet is hit, ALL damage from that hit is prevented (Turtle).
|
||||||
|
ActionShieldNext EffectAction = "shieldNext"
|
||||||
|
// ActionShieldSelf gives the pet itself Count charges that each prevent
|
||||||
|
// all damage from one hit (Gorilla on hurt, Melon on play).
|
||||||
|
ActionShieldSelf EffectAction = "shieldSelf"
|
||||||
|
// ActionStripFoods (play) discards every food attached to the enemy
|
||||||
|
// pet in play (losing their buffs and perks, which can faint it).
|
||||||
|
ActionStripFoods EffectAction = "stripFoods"
|
||||||
|
// ActionStealApples (play) moves up to Count apples from the enemy pet
|
||||||
|
// to this pet (Wolverine). Losing apples can faint the victim.
|
||||||
|
ActionStealApples EffectAction = "stealApples"
|
||||||
|
// ActionMillEnemy (play) discards cards off the top of the enemy deck
|
||||||
|
// until a non-Bee pet is showing (Chili).
|
||||||
|
ActionMillEnemy EffectAction = "millEnemy"
|
||||||
|
// ActionHeal removes Count damage markers from the pet, if it hasn't
|
||||||
|
// fainted.
|
||||||
|
ActionHeal EffectAction = "heal"
|
||||||
|
// ActionKnockout (passive) makes any pet this pet hurts with its clash
|
||||||
|
// attack faint outright (Scorpion). Rocks don't count, and a fully
|
||||||
|
// prevented attack KOs nothing.
|
||||||
|
ActionKnockout EffectAction = "knockout"
|
||||||
|
// ActionApplesInPlay (battle prep) starts the battle with Count apples
|
||||||
|
// already in play, attached to the owner's first pet (Monkey).
|
||||||
|
ActionApplesInPlay EffectAction = "applesInPlay"
|
||||||
|
// ActionDoubleApples doubles the apples in the player's hand (Cat).
|
||||||
|
// Shop-time.
|
||||||
|
ActionDoubleApples EffectAction = "doubleApples"
|
||||||
|
// ActionBeeAura (faint) sets the pet aside: the owner's Bees have
|
||||||
|
// +Count power for the rest of the battle (Turkey).
|
||||||
|
ActionBeeAura EffectAction = "beeAura"
|
||||||
|
// ActionPetAura (faint) sets the pet aside: the owner's pets have
|
||||||
|
// +Count power for the rest of the battle (Mammoth).
|
||||||
|
ActionPetAura EffectAction = "petAura"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Per multipliers for dynamic effect counts.
|
// Per multipliers for dynamic effect counts.
|
||||||
const PerFaintedBees = "faintedBees" // × friendly bees fainted this battle
|
const (
|
||||||
|
PerFaintedBees = "faintedBees" // × friendly bees fainted this battle
|
||||||
|
PerFaintedPets = "faintedPets" // × friendly pets fainted this battle
|
||||||
|
PerEatenApples = "eatenApples" // × apples this pet ate (its attached apples)
|
||||||
|
PerPower = "power" // × this pet's current power
|
||||||
|
)
|
||||||
|
|
||||||
|
// Effect conditions.
|
||||||
|
const (
|
||||||
|
ConditionTripled = "tripledThisRound" // player Tripled during this round's shop
|
||||||
|
ConditionHasPerk = "hasPerk" // this pet has a perk attached
|
||||||
|
)
|
||||||
|
|
||||||
// Effect is one trigger→action pair printed on a card.
|
// Effect is one trigger→action pair printed on a card.
|
||||||
type Effect struct {
|
type Effect struct {
|
||||||
@@ -90,6 +149,10 @@ type Effect struct {
|
|||||||
Target string `json:"target,omitempty"` // summonTop: "" (self) | "enemy"
|
Target string `json:"target,omitempty"` // summonTop: "" (self) | "enemy"
|
||||||
// Per multiplies Count by a battle statistic (e.g. PerFaintedBees).
|
// Per multiplies Count by a battle statistic (e.g. PerFaintedBees).
|
||||||
Per string `json:"per,omitempty"`
|
Per string `json:"per,omitempty"`
|
||||||
|
// Cap limits the final count when > 0 ("up to N").
|
||||||
|
Cap int `json:"cap,omitempty"`
|
||||||
|
// Condition gates the effect (e.g. ConditionTripled).
|
||||||
|
Condition string `json:"condition,omitempty"`
|
||||||
// MinRound gates the effect to round >= MinRound (0 = always).
|
// MinRound gates the effect to round >= MinRound (0 = always).
|
||||||
MinRound int `json:"minRound,omitempty"`
|
MinRound int `json:"minRound,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -148,10 +211,7 @@ type foodTemplate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1)
|
// petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1)
|
||||||
// through index 5 for tier 6 (round 6).
|
// through index 5 for tier 6 (round 6). All six tiers are real card data.
|
||||||
//
|
|
||||||
// Tiers 1-2 are real card data. Tiers 3-6 are placeholders with the same
|
|
||||||
// structure until their real definitions are provided.
|
|
||||||
var petTiers = [MaxRounds][]petTemplate{
|
var petTiers = [MaxRounds][]petTemplate{
|
||||||
{ // Tier 1
|
{ // Tier 1
|
||||||
{
|
{
|
||||||
@@ -253,16 +313,127 @@ var petTiers = [MaxRounds][]petTemplate{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Badger", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
|
Name: "Badger", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 2}},
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 2, Target: "all"}},
|
||||||
EffectText: "Faint: set aside — when you play your next pet, throw 2 Rocks at each active pet",
|
EffectText: "Faint: set aside — when you play your next pet, throw 2 Rocks at each active pet",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
placeholderTier([6]string{"Skunk", "Hippo", "Bison", "Deer", "Squirrel", "Whale"},
|
{ // Tier 4
|
||||||
[6]int{5, 6, 6, 4, 4, 5}),
|
{
|
||||||
placeholderTier([6]string{"Scorpion", "Rhino", "Monkey", "Cow", "Seal", "Shark"},
|
Name: "Squirrel", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
[6]int{5, 7, 6, 6, 6, 7}),
|
Effects: []Effect{
|
||||||
placeholderTier([6]string{"Leopard", "Boar", "Gorilla", "Mammoth", "Snake", "Tiger"},
|
{Trigger: TriggerBuy, Action: ActionGainApple, Count: 2},
|
||||||
[6]int{8, 9, 9, 10, 8, 9}),
|
{Trigger: TriggerSell, Action: ActionGainApple, Count: 2},
|
||||||
|
},
|
||||||
|
EffectText: "Buy: add 2 Apples to your hand · Sell: add 2 extra Apples to your hand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Turtle", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionShieldNext}},
|
||||||
|
EffectText: "Faint: set aside — the next time a friendly pet is hit, prevent all damage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Rooster", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Per: PerEatenApples, Cap: 3}},
|
||||||
|
EffectText: "Faint: add a Bee on top of your deck for each Apple this pet ate, up to 3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bison", Power: 3, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionGainApple, Count: 3, Condition: ConditionTripled}},
|
||||||
|
EffectText: "Battle Prep: if you Tripled during this round's shop, add 3 Apples to your hand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Blowfish", Power: 3, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionDelayedRocks, Count: 3}},
|
||||||
|
EffectText: "Faint: set aside — when you play your next pet, throw 3 Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Skunk", Power: 2, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStripFoods}},
|
||||||
|
EffectText: "Play: discard all Food from the enemy pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Hippo", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerEnemyFaint, Action: ActionHeal}},
|
||||||
|
EffectText: "Enemy Faints: if this pet hasn't fainted, heal 1 damage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 5
|
||||||
|
{
|
||||||
|
Name: "Monkey", Power: 3, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerBattlePrep, Action: ActionApplesInPlay, Count: 3}},
|
||||||
|
EffectText: "Battle Prep: start with 3 Apples in play, attached to your first pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Rhino", Power: 5, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerEnemyPlay, Action: ActionThrowRock}},
|
||||||
|
EffectText: "Enemy Played: throw 1 Rock",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Crocodile", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionEnemyLastPetRocks, Count: 3}},
|
||||||
|
EffectText: "Faint: set aside — when the last pet in the enemy deck is played, throw 3 Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Scorpion", Power: 1, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPassive, Action: ActionKnockout}},
|
||||||
|
EffectText: "This pet KOs any pet it hurts with an attack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Seal", Power: 3, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionSummonTop, Card: "apple", Count: 3, Condition: ConditionHasPerk}},
|
||||||
|
EffectText: "Play: if this pet has a Perk, add 3 Apples on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Shark", Power: 1, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionEatApple, Per: PerFaintedPets}},
|
||||||
|
EffectText: "Play: eats 1 Apple for each friendly fainted pet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Turkey", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionBeeAura}},
|
||||||
|
EffectText: "Faint: set aside — your Bees have +1 power",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ // Tier 6
|
||||||
|
{
|
||||||
|
Name: "Gorilla", Power: 6, Suits: []Suit{SuitBlue, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerHurt, Action: ActionShieldSelf}},
|
||||||
|
EffectText: "Hurt: the next time this pet is hit, prevent all damage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fly", Power: 4, Suits: []Suit{SuitRed, SuitYellow},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionSummonTop, Card: "bee", Count: 3}},
|
||||||
|
EffectText: "Faint: add 3 Bees on top of your deck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Leopard", Power: 4, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Per: PerPower}},
|
||||||
|
EffectText: "Play: throw Rocks equal to this pet's power",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Mammoth", Power: 4, Suits: []Suit{SuitYellow, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionPetAura}},
|
||||||
|
EffectText: "Faint: set aside — your pets have +1 power",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Cat", Power: 2, Suits: []Suit{SuitYellow, SuitBlue},
|
||||||
|
Effects: []Effect{
|
||||||
|
{Trigger: TriggerBuy, Action: ActionGainApple, Count: 2},
|
||||||
|
{Trigger: TriggerBuy, Action: ActionDoubleApples},
|
||||||
|
},
|
||||||
|
EffectText: "Buy: add 2 Apples to your hand, then double your Apples in hand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Snake", Power: 2, Suits: []Suit{SuitRed, SuitBlue},
|
||||||
|
Effects: []Effect{{Trigger: TriggerFaint, Action: ActionRecurringRocks, Count: 2}},
|
||||||
|
EffectText: "Faint: set aside — each time you play a pet, throw 2 Rocks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Wolverine", Power: 5, Suits: []Suit{SuitBlue, SuitRed},
|
||||||
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionStealApples, Count: 3}},
|
||||||
|
EffectText: "Play: steal up to 3 Apples from the enemy pet",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// foodTiers defines the food cards mixed into each tier's shop deck.
|
// foodTiers defines the food cards mixed into each tier's shop deck.
|
||||||
@@ -282,25 +453,27 @@ var foodTiers = [MaxRounds][]foodTemplate{
|
|||||||
EffectText: "Every attack that hits this pet deals 1 less damage",
|
EffectText: "Every attack that hits this pet deals 1 less damage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{}, {}, {},
|
{ // Tier 4
|
||||||
}
|
{
|
||||||
|
Name: "Pineapple", Food: FoodPineapple, Copies: 2, Perk: true,
|
||||||
// placeholderTier mirrors tier 1's suit distribution (each suit appears on 4
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 3}},
|
||||||
// of the 12 cards) for pets whose real definitions aren't in yet.
|
EffectText: "Play: throw 3 Rocks",
|
||||||
func placeholderTier(names [6]string, powers [6]int) []petTemplate {
|
},
|
||||||
suitPairs := [6][]Suit{
|
},
|
||||||
{SuitBlue, SuitYellow},
|
{ // Tier 5
|
||||||
{SuitRed, SuitBlue},
|
{
|
||||||
{SuitYellow, SuitBlue},
|
Name: "Chili", Food: FoodChili, Copies: 2, Perk: true,
|
||||||
{SuitYellow, SuitRed},
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionMillEnemy}},
|
||||||
{SuitRed, SuitBlue},
|
EffectText: "Play: discard cards from the top of the enemy deck until a non-Bee pet is showing",
|
||||||
{SuitYellow, SuitRed},
|
},
|
||||||
}
|
},
|
||||||
tier := make([]petTemplate, 6)
|
{ // Tier 6
|
||||||
for i := range names {
|
{
|
||||||
tier[i] = petTemplate{Name: names[i], Power: powers[i], Suits: suitPairs[i]}
|
Name: "Melon", Food: FoodMelon, Copies: 2, Perk: true,
|
||||||
}
|
Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf}},
|
||||||
return tier
|
EffectText: "The first time this pet is hit, prevent all damage",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCardID mints a unique card ID within the game.
|
// newCardID mints a unique card ID within the game.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestRandomBattlesTerminate throws thousands of random decks (drawn from
|
||||||
|
// every tier's real cards, plus apples, bees, and perks) at the battle
|
||||||
|
// resolver, checking it always terminates with a sane event log and a
|
||||||
|
// consistent winner. This is the safety net for effect interactions no
|
||||||
|
// hand-written scenario covers.
|
||||||
|
func TestRandomBattlesTerminate(t *testing.T) {
|
||||||
|
names := []string{}
|
||||||
|
for _, tier := range petTiers {
|
||||||
|
for _, tmpl := range tier {
|
||||||
|
names = append(names, tmpl.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foods := []string{"Honey", "Garlic", "Pineapple", "Chili", "Melon"}
|
||||||
|
|
||||||
|
for i := range 3000 {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
buildDeck := func() []Card {
|
||||||
|
deck := make([]Card, 0, 10)
|
||||||
|
for range 1 + randInt(6) {
|
||||||
|
switch randInt(5) {
|
||||||
|
case 0:
|
||||||
|
deck = append(deck, g.newApple())
|
||||||
|
case 1:
|
||||||
|
deck = append(deck, g.realFood(t, foods[randInt(len(foods))]))
|
||||||
|
case 2:
|
||||||
|
deck = append(deck, g.newBee())
|
||||||
|
default:
|
||||||
|
deck = append(deck, g.realPet(t, names[randInt(len(names))]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deck
|
||||||
|
}
|
||||||
|
g.Round = 1 + randInt(MaxRounds)
|
||||||
|
res := forceBattle(t, g, buildDeck(), buildDeck())
|
||||||
|
if len(res.Events) > 2000 {
|
||||||
|
t.Fatalf("battle %d produced a runaway event log (%d events)", i, len(res.Events))
|
||||||
|
}
|
||||||
|
if res.WinnerSeat < -1 || res.WinnerSeat > 1 {
|
||||||
|
t.Fatalf("battle %d: bogus winner %d", i, res.WinnerSeat)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat >= 0 && res.Trophies < 1 {
|
||||||
|
t.Fatalf("battle %d: winner without trophies", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-1
@@ -45,6 +45,9 @@ type Player struct {
|
|||||||
Trophies int `json:"trophies"`
|
Trophies int `json:"trophies"`
|
||||||
Ready bool `json:"ready"` // arrange submitted / battle acknowledged
|
Ready bool `json:"ready"` // arrange submitted / battle acknowledged
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
|
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||||
|
// action during the current round's shop (Bison's Battle Prep).
|
||||||
|
TripledThisRound bool `json:"tripledThisRound"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PetCount counts pet cards in the player's deck.
|
// PetCount counts pet cards in the player's deck.
|
||||||
@@ -207,6 +210,7 @@ func (g *Game) startShopRound() {
|
|||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
p.Coins = CoinsPerRound
|
p.Coins = CoinsPerRound
|
||||||
p.Ready = false
|
p.Ready = false
|
||||||
|
p.TripledThisRound = false
|
||||||
}
|
}
|
||||||
g.ShopRow = make([]Card, ShopRowSize)
|
g.ShopRow = make([]Card, ShopRowSize)
|
||||||
for i := range g.ShopRow {
|
for i := range g.ShopRow {
|
||||||
@@ -285,12 +289,17 @@ func (g *Game) Sell(playerID string, cardIDs []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyShopTrigger fires a shop-time trigger (buy/sell/triple) on one card.
|
// applyShopTrigger fires a shop-time trigger (buy/sell/triple/battle prep)
|
||||||
|
// on one card. Battle-time actions on the same trigger (Monkey's
|
||||||
|
// applesInPlay) are ignored here and handled by the battle resolver.
|
||||||
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
||||||
for _, e := range c.Effects {
|
for _, e := range c.Effects {
|
||||||
if e.Trigger != trigger || g.Round < e.MinRound {
|
if e.Trigger != trigger || g.Round < e.MinRound {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if e.Condition == ConditionTripled && !p.TripledThisRound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch e.Action {
|
switch e.Action {
|
||||||
case ActionGainApple:
|
case ActionGainApple:
|
||||||
for range e.count() {
|
for range e.count() {
|
||||||
@@ -298,6 +307,16 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
|||||||
}
|
}
|
||||||
case ActionRefreshGold:
|
case ActionRefreshGold:
|
||||||
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
||||||
|
case ActionDoubleApples:
|
||||||
|
apples := 0
|
||||||
|
for _, dc := range p.Deck {
|
||||||
|
if dc.Food == FoodApple {
|
||||||
|
apples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range apples {
|
||||||
|
p.Deck = append(p.Deck, g.newApple())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +388,7 @@ func (g *Game) TradeStart(playerID string, cardIDs []string) error {
|
|||||||
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
||||||
}
|
}
|
||||||
p.Coins--
|
p.Coins--
|
||||||
|
p.TripledThisRound = true
|
||||||
g.Pending = &PendingTrade{
|
g.Pending = &PendingTrade{
|
||||||
PlayerID: playerID,
|
PlayerID: playerID,
|
||||||
Tier: nextTier,
|
Tier: nextTier,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,7 +172,8 @@ func TestTradeInThreeMatchingSuits(t *testing.T) {
|
|||||||
if err := g.TradeChoose(p.ID, 0); err != nil {
|
if err := g.TradeChoose(p.ID, 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(p.Deck) != 1 || p.Deck[0].ID != chosen.ID {
|
// The chosen card joins the deck (its Buy effect may add apples too).
|
||||||
|
if slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == chosen.ID }) < 0 {
|
||||||
t.Fatal("chosen card should join the deck")
|
t.Fatal("chosen card should join the deck")
|
||||||
}
|
}
|
||||||
deck2 := g.ShopDecks[1]
|
deck2 := g.ShopDecks[1]
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// --- Tier 4 ---
|
||||||
|
|
||||||
|
func countApplesIn(deck []Card) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range deck {
|
||||||
|
if c.Food == FoodApple {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSquirrelBuyAndSell(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
p := current(g)
|
||||||
|
g.ShopRow[0] = g.realPet(t, "Squirrel")
|
||||||
|
if err := g.Buy(p.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if countApplesIn(p.Deck) != 2 {
|
||||||
|
t.Fatalf("buying a Squirrel should add 2 apples, got %d", countApplesIn(p.Deck))
|
||||||
|
}
|
||||||
|
// Sell it back on the next turn: 1 base + 2 extra = 3 more apples.
|
||||||
|
if err := g.Buy(current(g).ID, 0); err != nil { // opponent spends a coin
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var squirrelID string
|
||||||
|
for _, c := range p.Deck {
|
||||||
|
if c.Name == "Squirrel" {
|
||||||
|
squirrelID = c.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := g.Sell(p.ID, []string{squirrelID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if countApplesIn(p.Deck) != 5 {
|
||||||
|
t.Fatalf("selling a Squirrel should add 3 apples (1 base + 2 extra), got %d total", countApplesIn(p.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turtle's faint shields the next friendly hit completely.
|
||||||
|
func TestTurtleShieldsNextFriendlyHit(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Turtle"), g.pet("Ally", 3)}, // turtle: 2
|
||||||
|
[]Card{g.pet("Big", 5)},
|
||||||
|
)
|
||||||
|
// Turtle dies to the 5 (Big takes 2). Ally's first hit is fully
|
||||||
|
// blocked; Ally hits back for 3: Big at 5 damage = dead. Ally wins
|
||||||
|
// unscathed.
|
||||||
|
shields := eventsOfType(res, "shield")
|
||||||
|
if len(shields) != 1 || shields[0].Seat != 0 {
|
||||||
|
t.Fatalf("expected one shield block for seat 0: %+v", shields)
|
||||||
|
}
|
||||||
|
clashes := eventsOfType(res, "clash")
|
||||||
|
final := clashes[len(clashes)-1]
|
||||||
|
if final.Damage[0] != 0 || !final.Died[1] {
|
||||||
|
t.Fatalf("ally should take 0 damage while Big dies: %+v", final)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pineapple is a perk that gives its pet "Play: throw 3 Rocks".
|
||||||
|
func TestPineapplePerkThrowsRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realFood(t, "Pineapple"), g.pet("Holder", 2)},
|
||||||
|
[]Card{g.pet("Tank", 3)},
|
||||||
|
)
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Roll != 3 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("pineapple should throw 3 rocks and kill the 3-power tank: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rooster summons a bee per apple it ate, capped at 3.
|
||||||
|
func TestRoosterBeesPerEatenAppleCapped(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.realPet(t, "Rooster")}, // 4+4=8
|
||||||
|
[]Card{g.pet("Colossus", 9)},
|
||||||
|
)
|
||||||
|
summons := eventsOfType(res, "summon")
|
||||||
|
if len(summons) != 3 {
|
||||||
|
t.Fatalf("rooster ate 4 apples but summons cap at 3 bees: %+v", summons)
|
||||||
|
}
|
||||||
|
for _, s := range summons {
|
||||||
|
if s.Card.Name != "Bee" {
|
||||||
|
t.Fatalf("rooster summons bees: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Colossus (9) takes 8 from rooster, dies to the first bee; seat 0
|
||||||
|
// still has bees to field and wins.
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bison's Battle Prep needs a Triple this round.
|
||||||
|
func TestBisonBattlePrepRequiresTriple(t *testing.T) {
|
||||||
|
for _, tripled := range []bool{false, true} {
|
||||||
|
g, p1, _ := testGame(t)
|
||||||
|
p1.Deck = append(p1.Deck, g.realPet(t, "Bison"))
|
||||||
|
if tripled {
|
||||||
|
var ids []string
|
||||||
|
for range 3 {
|
||||||
|
c := g.pet("Fodder", 1)
|
||||||
|
c.Suit = SuitBlue
|
||||||
|
p1.Deck = append(p1.Deck, c)
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
g.Turn = p1.Seat
|
||||||
|
if err := g.TradeStart(p1.ID, ids); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := g.TradeChoose(p1.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spendAllCoins(t, g)
|
||||||
|
want := 0
|
||||||
|
if tripled {
|
||||||
|
want = 3
|
||||||
|
}
|
||||||
|
// Count only apples (the trade-chosen card may have added some via
|
||||||
|
// its own Buy effect — exclude by counting before/after? Instead,
|
||||||
|
// require at least; strict equality only in the no-trade case).
|
||||||
|
got := countApplesIn(p1.Deck)
|
||||||
|
if !tripled && got != want {
|
||||||
|
t.Fatalf("no triple: bison should add no apples, got %d", got)
|
||||||
|
}
|
||||||
|
if tripled && got < want {
|
||||||
|
t.Fatalf("tripled: bison should add 3 apples, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blowfish volleys 3 rocks at the enemy only when the next pet plays.
|
||||||
|
func TestBlowfishDelayedRocksEnemyOnly(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Blowfish"), g.pet("Next", 2)}, // blowfish: 3
|
||||||
|
[]Card{g.pet("Tank", 6)},
|
||||||
|
)
|
||||||
|
// Blowfish dies (takes 6), Tank keeps 3 damage. Next plays → 3 rocks
|
||||||
|
// at the enemy only: Tank at 6 damage = dead. Seat 0 wins; Next is
|
||||||
|
// untouched.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Target != 1 || rocks[0].Roll != 3 {
|
||||||
|
t.Fatalf("blowfish should volley the enemy only: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skunk strips the enemy pet's foods; a wounded veteran can faint from the
|
||||||
|
// power loss.
|
||||||
|
func TestSkunkStripCanKill(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("Chip", 3), g.realPet(t, "Skunk")},
|
||||||
|
[]Card{g.newApple(), g.newApple(), g.pet("Wall", 2)}, // 2+2 = 4 power
|
||||||
|
)
|
||||||
|
// Chip (3) dies to Wall (4); Wall carries 3 damage (1 health). Skunk
|
||||||
|
// plays and strips both apples: Wall drops to 2 power with 3 damage —
|
||||||
|
// it faints on the spot, no clash needed.
|
||||||
|
strips := eventsOfType(res, "strip")
|
||||||
|
if len(strips) != 1 || strips[0].Target != 1 || !strips[0].TargetDied {
|
||||||
|
t.Fatalf("strip should kill the buffed veteran: %+v", strips)
|
||||||
|
}
|
||||||
|
if len(eventsOfType(res, "clash")) != 1 {
|
||||||
|
t.Fatal("only the first clash should happen")
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hippo heals 1 damage whenever an enemy pet faints.
|
||||||
|
func TestHippoHealsOnEnemyFaint(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Hippo")}, // 4 power
|
||||||
|
[]Card{g.pet("Chip", 2), g.pet("Chip2", 2)},
|
||||||
|
)
|
||||||
|
heals := eventsOfType(res, "heal")
|
||||||
|
if len(heals) != 2 {
|
||||||
|
t.Fatalf("hippo should heal on each enemy faint: %+v", heals)
|
||||||
|
}
|
||||||
|
if heals[0].DamageAfter != 1 || heals[1].DamageAfter != 2 {
|
||||||
|
t.Fatalf("hippo damage should go 2→1 then 3→2: %+v", heals)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("hippo should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 5 ---
|
||||||
|
|
||||||
|
// Monkey starts the battle with 3 apples attached to the first pet.
|
||||||
|
func TestMonkeyApplesInPlay(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("First", 2), g.realPet(t, "Monkey")}, // monkey: 3
|
||||||
|
[]Card{g.pet("Enemy", 5)},
|
||||||
|
)
|
||||||
|
preps := eventsOfType(res, "prep")
|
||||||
|
if len(preps) != 3 || preps[0].Seat != 0 {
|
||||||
|
t.Fatalf("monkey should start 3 apples in play for seat 0: %+v", preps)
|
||||||
|
}
|
||||||
|
// First fights at 2+3=5 vs 5: both die. Monkey (3) then beats nothing —
|
||||||
|
// enemy is out. Seat 0 wins.
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rhino rocks every enemy pet as it's played.
|
||||||
|
func TestRhinoRocksEnemyPlays(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Rhino")}, // 5 power
|
||||||
|
[]Card{g.pet("A", 1), g.pet("B", 6)},
|
||||||
|
)
|
||||||
|
// A is played → rhino rocks it dead (1 damage ≥ 1 power) before any
|
||||||
|
// clash. B is played → rocked for 1, then clashes: B (6, dmg 1) takes
|
||||||
|
// 5 → dead at 6; rhino takes 6 → dead at 5. Both out: draw.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 2 || rocks[0].Seat != 0 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("rhino should rock each played enemy pet: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crocodile's set-aside fires when the enemy plays their last pet.
|
||||||
|
func TestCrocodileLastPetRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Crocodile"), g.pet("Anchor", 9)}, // croc: 4
|
||||||
|
[]Card{g.pet("Tank", 4), g.pet("Last", 4)},
|
||||||
|
)
|
||||||
|
// Croc and Tank (4v4) kill each other; croc sets aside. Anchor and
|
||||||
|
// Last are played — Last is the enemy's last pet, so croc volleys 3
|
||||||
|
// rocks (3 damage): Last at 3 damage. Clash: Last dies (3+9), Anchor
|
||||||
|
// takes 4. Seat 0 wins.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Target != 1 || rocks[0].Roll != 3 {
|
||||||
|
t.Fatalf("crocodile should volley the enemy's last pet: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scorpion KOs whatever its clash attack hurts — but a fully prevented
|
||||||
|
// attack (Garlic on a 1-power hit) KOs nothing, and its rocks never KO.
|
||||||
|
func TestScorpionKnockout(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Scorpion")}, // 1 power
|
||||||
|
[]Card{g.pet("Giant", 9)},
|
||||||
|
)
|
||||||
|
clash := eventsOfType(res, "clash")[0]
|
||||||
|
if !clash.Died[1] {
|
||||||
|
t.Fatalf("scorpion's hit should KO the giant: %+v", clash)
|
||||||
|
}
|
||||||
|
if !clash.Died[0] {
|
||||||
|
t.Fatal("the giant's 9 attack still kills the scorpion")
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected mutual destruction draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGarlicBlocksScorpionKO(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Scorpion")},
|
||||||
|
[]Card{g.realFood(t, "Garlic"), g.pet("Guard", 9)},
|
||||||
|
)
|
||||||
|
// Scorpion's 1 attack is fully prevented by garlic: no hurt, no KO.
|
||||||
|
clash := eventsOfType(res, "clash")[0]
|
||||||
|
if clash.Died[1] {
|
||||||
|
t.Fatalf("garlic should negate the scorpion's KO: %+v", clash)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 1 {
|
||||||
|
t.Fatalf("guard should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal only pays out if it carries a perk.
|
||||||
|
func TestSealNeedsPerk(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
// Without a perk: nothing.
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Seal")},
|
||||||
|
[]Card{g.pet("Enemy", 9)},
|
||||||
|
)
|
||||||
|
if len(eventsOfType(res, "summon")) != 0 {
|
||||||
|
t.Fatal("seal without a perk should summon nothing")
|
||||||
|
}
|
||||||
|
// With honey attached: 3 apples onto the deck.
|
||||||
|
g2, _, _ := testGame(t)
|
||||||
|
res2 := forceBattle(t, g2,
|
||||||
|
[]Card{g2.newHoney(t), g2.realPet(t, "Seal"), g2.pet("Heir", 3)},
|
||||||
|
[]Card{g2.pet("Enemy", 9)},
|
||||||
|
)
|
||||||
|
apples := 0
|
||||||
|
for _, s := range eventsOfType(res2, "summon") {
|
||||||
|
if s.Card.Food == FoodApple && s.Seat == 0 {
|
||||||
|
apples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apples != 3 {
|
||||||
|
t.Fatalf("perked seal should push 3 apples onto its deck, got %d", apples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shark eats an apple per fainted friendly pet.
|
||||||
|
func TestSharkEatsPerFaintedPet(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("A", 1), g.pet("B", 1), g.realPet(t, "Shark")}, // shark: 1
|
||||||
|
[]Card{g.pet("Wall", 3)},
|
||||||
|
)
|
||||||
|
// A and B faint chipping Wall to 2 damage (1 health). Shark plays with
|
||||||
|
// 2 fainted friends → eats 2 → 3 power. Clash: Wall dies (3 more
|
||||||
|
// damage), shark survives 3 damage? 3 damage vs 3 power = fainted too.
|
||||||
|
eats := eventsOfType(res, "eat")
|
||||||
|
if len(eats) != 1 || eats[0].Bonus != 2 {
|
||||||
|
t.Fatalf("shark should eat 2 apples: %+v", eats)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw (both die in final clash), got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turkey's aura buffs later friendly bees.
|
||||||
|
func TestTurkeyBuffsBees(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Turkey"), g.tier1(t, "Cricket")}, // turkey 4, cricket 1
|
||||||
|
[]Card{g.pet("Wall", 6)},
|
||||||
|
)
|
||||||
|
// Turkey dies (Wall at 4 damage, 2 health), aura set. Cricket dies
|
||||||
|
// (Wall at 5 damage, 1 health), summons its bee. The bee plays at
|
||||||
|
// 1+1=2 power: kills Wall and survives with 6 damage? No — bee takes
|
||||||
|
// 6, dies; Wall takes 2, dies at 7 ≥ 6. Draw... but the bee had 2
|
||||||
|
// power so Wall dies; bee dies too. Draw.
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
// Verify the aura actually applied: final clash damage on Wall is
|
||||||
|
// 5+2=7 (a 1-power bee would leave it at 6 exactly = still dead...);
|
||||||
|
// check damage total instead.
|
||||||
|
clashes := eventsOfType(res, "clash")
|
||||||
|
final := clashes[len(clashes)-1]
|
||||||
|
if final.Damage[1] != 7 {
|
||||||
|
t.Fatalf("buffed bee should hit for 2 (wall at 7 damage), got %d", final.Damage[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chili mills the enemy deck down to the next non-Bee pet.
|
||||||
|
func TestChiliMillsEnemyDeck(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
bee := g.newBee()
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realFood(t, "Chili"), g.pet("Holder", 3)},
|
||||||
|
[]Card{g.pet("Front", 3), g.newApple(), bee, g.pet("Real", 3)},
|
||||||
|
)
|
||||||
|
// Holder (with chili) and Front play. Chili mills: apple, bee are
|
||||||
|
// discarded; Real stays on top. Clash: Holder and Front trade (3v3,
|
||||||
|
// both die). Real vs nothing — seat 1 wins.
|
||||||
|
mills := eventsOfType(res, "mill")
|
||||||
|
if len(mills) != 2 || mills[0].Seat != 1 {
|
||||||
|
t.Fatalf("chili should mill exactly the apple and bee: %+v", mills)
|
||||||
|
}
|
||||||
|
if mills[0].Card.Food != FoodApple || !isBee(*mills[1].Card) {
|
||||||
|
t.Fatalf("milled cards should be the apple then the bee: %+v", mills)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 1 {
|
||||||
|
t.Fatalf("seat 1's Real pet should carry the day, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tier 6 ---
|
||||||
|
|
||||||
|
// Gorilla is hurt only every other hit.
|
||||||
|
func TestGorillaAlternatingShield(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Gorilla")}, // 6 power
|
||||||
|
[]Card{g.pet("A", 2), g.pet("B", 2), g.pet("C", 2)},
|
||||||
|
)
|
||||||
|
// Hit 1: takes 2 (damage 2), gains shield. Hit 2: blocked. Hit 3:
|
||||||
|
// takes 2 (damage 4), gains shield. Gorilla kills all three.
|
||||||
|
shields := eventsOfType(res, "shield")
|
||||||
|
if len(shields) != 1 || shields[0].Seat != 0 {
|
||||||
|
t.Fatalf("gorilla should block exactly the second hit: %+v", shields)
|
||||||
|
}
|
||||||
|
clashes := eventsOfType(res, "clash")
|
||||||
|
if len(clashes) != 3 || clashes[2].Damage[0] != 4 {
|
||||||
|
t.Fatalf("gorilla should end at 4 damage after 3 clashes: %+v", clashes)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("gorilla should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Melon blocks the first hit against its pet.
|
||||||
|
func TestMelonBlocksFirstHit(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realFood(t, "Melon"), g.pet("Holder", 3)},
|
||||||
|
[]Card{g.pet("Big", 5)},
|
||||||
|
)
|
||||||
|
// Hit 1 blocked; holder hits Big for 3. Hit 2: holder takes 5, dies;
|
||||||
|
// Big takes 3 more → 6 ≥ 5, dies. Draw.
|
||||||
|
shields := eventsOfType(res, "shield")
|
||||||
|
if len(shields) != 1 || shields[0].Seat != 0 {
|
||||||
|
t.Fatalf("melon should block the first hit: %+v", shields)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leopard throws rocks equal to its power, apples included.
|
||||||
|
func TestLeopardRocksEqualPower(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.newApple(), g.newApple(), g.newApple(), g.realPet(t, "Leopard")}, // 4+3=7
|
||||||
|
[]Card{g.pet("Fortress", 7)},
|
||||||
|
)
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 1 || rocks[0].Roll != 7 || !rocks[0].TargetDied {
|
||||||
|
t.Fatalf("leopard should throw 7 rocks (7 damage) and level the fortress: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mammoth's aura buffs all later friendly pets.
|
||||||
|
func TestMammothBuffsPets(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Mammoth"), g.pet("Heir", 3)}, // mammoth: 4
|
||||||
|
[]Card{g.pet("Wall", 8)},
|
||||||
|
)
|
||||||
|
// Mammoth dies (Wall at 4 damage). Heir plays at 3+1=4: dies to the 8,
|
||||||
|
// but Wall ends at 8 damage ≥ 8 → dead too. Draw proves the +1.
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw (aura makes exactly lethal), got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cat doubles the apples in hand after adding two.
|
||||||
|
func TestCatDoublesApples(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
p := current(g)
|
||||||
|
p.Deck = append(p.Deck, g.newApple()) // 1 apple already in hand
|
||||||
|
g.ShopRow[0] = g.realPet(t, "Cat")
|
||||||
|
if err := g.Buy(p.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 1 existing + 2 added = 3, doubled = 6.
|
||||||
|
if countApplesIn(p.Deck) != 6 {
|
||||||
|
t.Fatalf("cat should leave 6 apples ((1+2)×2), got %d", countApplesIn(p.Deck))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snake volleys on EVERY friendly pet play.
|
||||||
|
func TestSnakeRecurringRocks(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
g.RollDie = func() int { return 1 }
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Snake"), g.pet("A", 2), g.pet("B", 2)}, // snake: 2
|
||||||
|
[]Card{g.pet("Wall", 9)},
|
||||||
|
)
|
||||||
|
// Snake dies. A plays → 2 rocks (2 damage) → Wall 2+2... sequence:
|
||||||
|
// snake clash: wall 2 damage. A plays → rocks 2 → wall 4. Clash: A
|
||||||
|
// dies, wall 6. B plays → rocks 2 → wall 8. Clash: B dies, wall 10 ≥ 9
|
||||||
|
// → dead. Both out → draw, with TWO rock volleys proving recurrence.
|
||||||
|
rocks := eventsOfType(res, "rock")
|
||||||
|
if len(rocks) != 2 {
|
||||||
|
t.Fatalf("snake should volley on each friendly play: %+v", rocks)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wolverine steals up to 3 apples, which can faint a wounded victim.
|
||||||
|
func TestWolverineStealsApples(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5
|
||||||
|
[]Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.pet("Hoard", 2)}, // 2+4=6
|
||||||
|
)
|
||||||
|
// Chip (4) dies to Hoard (6); Hoard carries 4 damage (2 health).
|
||||||
|
// Wolverine plays, steals 3 apples: Hoard drops to 3 power with 4
|
||||||
|
// damage → faints. Wolverine ends at 5+3=8 power.
|
||||||
|
steals := eventsOfType(res, "steal")
|
||||||
|
if len(steals) != 1 || steals[0].Count != 3 || !steals[0].TargetDied {
|
||||||
|
t.Fatalf("wolverine should steal 3 apples and kill the hoard: %+v", steals)
|
||||||
|
}
|
||||||
|
if res.WinnerSeat != 0 {
|
||||||
|
t.Fatalf("seat 0 should win, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fly summons three bees on faint.
|
||||||
|
func TestFlySummonsThreeBees(t *testing.T) {
|
||||||
|
g, _, _ := testGame(t)
|
||||||
|
res := forceBattle(t, g,
|
||||||
|
[]Card{g.realPet(t, "Fly")}, // 4 power
|
||||||
|
[]Card{g.pet("Wall", 7)},
|
||||||
|
)
|
||||||
|
summons := eventsOfType(res, "summon")
|
||||||
|
if len(summons) != 3 {
|
||||||
|
t.Fatalf("fly should summon 3 bees: %+v", summons)
|
||||||
|
}
|
||||||
|
// Wall takes 4+1+1+1 = 7 → dies with the last bee. Draw.
|
||||||
|
if res.WinnerSeat != -1 {
|
||||||
|
t.Fatalf("expected draw, got %d", res.WinnerSeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,27 @@ interface UnitVis {
|
|||||||
|
|
||||||
interface SideVis {
|
interface SideVis {
|
||||||
stack: number
|
stack: number
|
||||||
pending: Card[] // revealed foods waiting for a pet
|
pending: Card[] // foods revealed (or prepped) waiting for a pet
|
||||||
unit: UnitVis | null
|
unit: UnitVis | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Milliseconds each event type stays on screen during playback.
|
// Milliseconds each event type stays on screen during playback.
|
||||||
const EVENT_MS: Record<BattleEvent['type'], number> = {
|
const EVENT_MS: Record<BattleEvent['type'], number> = {
|
||||||
|
prep: 700,
|
||||||
reveal: 800,
|
reveal: 800,
|
||||||
summon: 1000,
|
summon: 1000,
|
||||||
|
mill: 700,
|
||||||
rock: 1200,
|
rock: 1200,
|
||||||
clash: 1400,
|
clash: 1400,
|
||||||
|
shield: 900,
|
||||||
|
strip: 1100,
|
||||||
|
steal: 1100,
|
||||||
eat: 1000,
|
eat: 1000,
|
||||||
|
heal: 900,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appleCount = (foods: Card[]) => foods.filter((f) => f.food === 'apple').length
|
||||||
|
|
||||||
// replay applies the first `upto` events to fresh stacks and returns each
|
// replay applies the first `upto` events to fresh stacks and returns each
|
||||||
// seat's visual state. Units that died in the last applied event are still
|
// seat's visual state. Units that died in the last applied event are still
|
||||||
// present with dying=true so they can animate out.
|
// present with dying=true so they can animate out.
|
||||||
@@ -42,6 +50,9 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
}
|
}
|
||||||
const ev = events[k]
|
const ev = events[k]
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
|
case 'prep':
|
||||||
|
sides[ev.seat!].pending.push(ev.card!)
|
||||||
|
break
|
||||||
case 'reveal': {
|
case 'reveal': {
|
||||||
const s = sides[ev.seat!]
|
const s = sides[ev.seat!]
|
||||||
s.stack--
|
s.stack--
|
||||||
@@ -52,7 +63,7 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
s.unit = {
|
s.unit = {
|
||||||
card,
|
card,
|
||||||
foods: s.pending,
|
foods: s.pending,
|
||||||
bonus: s.pending.filter((f) => f.food === 'apple').length,
|
bonus: ev.bonus ?? appleCount(s.pending),
|
||||||
damage: 0,
|
damage: 0,
|
||||||
dying: false,
|
dying: false,
|
||||||
}
|
}
|
||||||
@@ -63,6 +74,9 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
case 'summon':
|
case 'summon':
|
||||||
sides[ev.seat!].stack++
|
sides[ev.seat!].stack++
|
||||||
break
|
break
|
||||||
|
case 'mill':
|
||||||
|
sides[ev.seat!].stack--
|
||||||
|
break
|
||||||
case 'rock': {
|
case 'rock': {
|
||||||
const u = sides[ev.target!].unit
|
const u = sides[ev.target!].unit
|
||||||
if (u) {
|
if (u) {
|
||||||
@@ -78,16 +92,81 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side
|
|||||||
if (ev.died?.[seat]) s.unit.dying = true
|
if (ev.died?.[seat]) s.unit.dying = true
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case 'strip': {
|
||||||
|
const u = sides[ev.target!].unit
|
||||||
|
if (u) {
|
||||||
|
u.bonus -= appleCount(u.foods)
|
||||||
|
u.foods = []
|
||||||
|
if (ev.targetDied) u.dying = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'steal': {
|
||||||
|
const from = sides[ev.target!].unit
|
||||||
|
const to = sides[ev.seat!].unit
|
||||||
|
const n = ev.count ?? 0
|
||||||
|
if (from && to) {
|
||||||
|
let moved = 0
|
||||||
|
from.foods = from.foods.filter((f) => {
|
||||||
|
if (f.food === 'apple' && moved < n) {
|
||||||
|
moved++
|
||||||
|
to.foods.push(f)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
from.bonus -= moved
|
||||||
|
to.bonus += moved
|
||||||
|
if (ev.targetDied) from.dying = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'eat': {
|
case 'eat': {
|
||||||
const u = sides[ev.seat!].unit
|
const u = sides[ev.seat!].unit
|
||||||
if (u) u.bonus = ev.bonus ?? u.bonus
|
if (u) u.bonus = ev.bonus ?? u.bonus
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'heal': {
|
||||||
|
const u = sides[ev.seat!].unit
|
||||||
|
if (u) u.damage = ev.damageAfter ?? u.damage
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'shield':
|
||||||
|
break // pure animation; no state change
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sides
|
return sides
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unitPop decides the floating effect text over a seat's pet for the event
|
||||||
|
// currently playing. Null = nothing.
|
||||||
|
function unitPop(ev: BattleEvent | null, seat: number, events: BattleEvent[], step: number): string | null {
|
||||||
|
if (!ev) return null
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'rock':
|
||||||
|
if (ev.target !== seat) return null
|
||||||
|
return ev.roll === 0 ? 'miss!' : `−${ev.roll}`
|
||||||
|
case 'clash': {
|
||||||
|
const taken = clashDamageTaken(events, step - 1, seat)
|
||||||
|
return taken > 0 ? `−${taken}` : null
|
||||||
|
}
|
||||||
|
case 'shield':
|
||||||
|
return ev.seat === seat ? '🛡️' : null
|
||||||
|
case 'eat':
|
||||||
|
return ev.seat === seat ? '🍎' : null
|
||||||
|
case 'heal':
|
||||||
|
return ev.seat === seat ? '💚 +1' : null
|
||||||
|
case 'strip':
|
||||||
|
return ev.target === seat ? '💨' : null
|
||||||
|
case 'steal':
|
||||||
|
if (ev.seat === seat) return `+🍎×${ev.count}`
|
||||||
|
if (ev.target === seat) return `−🍎×${ev.count}`
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BattlePhase plays back the battle log: cards flip off each deck, rocks
|
// BattlePhase plays back the battle log: cards flip off each deck, rocks
|
||||||
// fly, pets clash, the fallen fade out, then the round result lands.
|
// fly, pets clash, the fallen fade out, then the round result lands.
|
||||||
export function BattlePhase({ view, send }: Props) {
|
export function BattlePhase({ view, send }: Props) {
|
||||||
@@ -122,9 +201,10 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
const clashing = !done && lastEvent?.type === 'clash' && s.unit && !s.unit.dying
|
||||||
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
const clashDying = lastEvent?.type === 'clash' && s.unit?.dying
|
||||||
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
|
const rockVictim = lastEvent?.type === 'rock' && lastEvent.target === seat
|
||||||
const eating = lastEvent?.type === 'eat' && lastEvent.seat === seat
|
|
||||||
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
const summoning = !done && lastEvent?.type === 'summon' && lastEvent.seat === seat
|
||||||
|
const milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat
|
||||||
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
|
const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat
|
||||||
|
const pop = done ? null : unitPop(lastEvent, seat, events, step)
|
||||||
|
|
||||||
const stackEl = (
|
const stackEl = (
|
||||||
<div className="stackpile">
|
<div className="stackpile">
|
||||||
@@ -140,6 +220,11 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
<CardView card={lastEvent.card} size="sm" />
|
<CardView card={lastEvent.card} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{milling && lastEvent?.card && (
|
||||||
|
<div className="summon-pop mill-pop">
|
||||||
|
<CardView card={lastEvent.card} size="sm" dead />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,16 +267,11 @@ export function BattlePhase({ view, send }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(lastEvent?.type === 'clash' || rockVictim) && !done && (
|
{pop && (
|
||||||
<div className="damage-pop">
|
<div key={`pop-${step}`} className={pop.startsWith('−') ? 'damage-pop' : 'fx-pop'}>
|
||||||
{lastEvent?.type === 'rock'
|
{pop}
|
||||||
? lastEvent.roll === 0
|
|
||||||
? 'miss!'
|
|
||||||
: `−${lastEvent.roll}`
|
|
||||||
: `−${clashDamageTaken(events, step - 1, seat)}`}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{eating && <div className="eat-pop">🍎 +1</div>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +364,7 @@ function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): num
|
|||||||
for (let k = idx - 1; k >= 0; k--) {
|
for (let k = idx - 1; k >= 0; k--) {
|
||||||
const e = events[k]
|
const e = events[k]
|
||||||
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
|
if (e.type === 'reveal' && e.seat === seat && e.card?.kind === 'pet') break
|
||||||
if (e.type === 'rock' && e.target === seat) {
|
if ((e.type === 'rock' || e.type === 'heal') && (e.target ?? e.seat) === seat) {
|
||||||
before = e.damageAfter ?? 0
|
before = e.damageAfter ?? 0
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -6,16 +6,19 @@ const PET_EMOJI: Record<string, string> = {
|
|||||||
// Tier 3
|
// Tier 3
|
||||||
Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡',
|
Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡',
|
||||||
// Tier 4
|
// Tier 4
|
||||||
Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌', Squirrel: '🐿️', Whale: '🐳',
|
Squirrel: '🐿️', Turtle: '🐢', Rooster: '🐓', Bison: '🦬', Blowfish: '🐡', Skunk: '🦨', Hippo: '🦛',
|
||||||
// Tier 5
|
// Tier 5
|
||||||
Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄', Seal: '🦭', Shark: '🦈',
|
Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃',
|
||||||
// Tier 6
|
// Tier 6
|
||||||
Leopard: '🐆', Boar: '🐗', Gorilla: '🦍', Mammoth: '🦣', Snake: '🐍', Tiger: '🐯',
|
Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺',
|
||||||
// Summons & foods
|
// Summons & foods
|
||||||
Bee: '🐝',
|
Bee: '🐝',
|
||||||
Apple: '🍎',
|
Apple: '🍎',
|
||||||
Honey: '🍯',
|
Honey: '🍯',
|
||||||
Garlic: '🧄',
|
Garlic: '🧄',
|
||||||
|
Pineapple: '🍍',
|
||||||
|
Chili: '🌶️',
|
||||||
|
Melon: '🍈',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function artFor(name: string): string {
|
export function artFor(name: string): string {
|
||||||
|
|||||||
+8
-3
@@ -931,7 +931,7 @@ h3 {
|
|||||||
animation: rock-fly-left 700ms ease-in forwards;
|
animation: rock-fly-left 700ms ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes eat-pop {
|
@keyframes fx-pop {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 6px) scale(0.6);
|
transform: translate(-50%, 6px) scale(0.6);
|
||||||
@@ -946,7 +946,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.eat-pop {
|
.fx-pop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -954,9 +954,14 @@ h3 {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #7be495;
|
color: #7be495;
|
||||||
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
|
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.5);
|
||||||
animation: eat-pop 1000ms ease forwards;
|
animation: fx-pop 1000ms ease forwards;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mill-pop .card {
|
||||||
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes clash-left {
|
@keyframes clash-left {
|
||||||
|
|||||||
+15
-3
@@ -38,18 +38,30 @@ export interface PendingTrade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleEvent {
|
export interface BattleEvent {
|
||||||
type: 'reveal' | 'summon' | 'rock' | 'clash' | 'eat'
|
type:
|
||||||
|
| 'prep'
|
||||||
|
| 'reveal'
|
||||||
|
| 'summon'
|
||||||
|
| 'mill'
|
||||||
|
| 'rock'
|
||||||
|
| 'clash'
|
||||||
|
| 'shield'
|
||||||
|
| 'strip'
|
||||||
|
| 'steal'
|
||||||
|
| 'eat'
|
||||||
|
| 'heal'
|
||||||
seat?: number
|
seat?: number
|
||||||
target?: number
|
target?: number
|
||||||
card?: Card
|
card?: Card
|
||||||
|
count?: number
|
||||||
// clash
|
// clash
|
||||||
damage?: number[]
|
damage?: number[]
|
||||||
died?: boolean[]
|
died?: boolean[]
|
||||||
// rock
|
// rock / strip / steal
|
||||||
roll?: number
|
roll?: number
|
||||||
damageAfter?: number
|
damageAfter?: number
|
||||||
targetDied?: boolean
|
targetDied?: boolean
|
||||||
// eat
|
// eat (new bonus total) / reveal (starting bonus)
|
||||||
bonus?: number
|
bonus?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user