Add pets for tiers 4-6.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:10:20 -04:00
parent 8d43bbf61e
commit 10ac457e2e
11 changed files with 1334 additions and 163 deletions
+24 -13
View File
@@ -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 15, 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 15, 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 13 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 46 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
+362 -91
View File
@@ -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()) return false
u.Damage += dealt }
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,41 +377,95 @@ 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 {
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) 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})
}
}
}
// 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
} }
} }
} }
@@ -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 {
// Effects sourced from a specific unit fizzle if it's gone.
if q.unit != nil && sides[q.seat].unit != q.unit {
continue
}
if q.unit != nil && !allowed(q.effect, q.unit) {
continue
}
switch q.effect.Action { switch q.effect.Action {
case ActionThrowRock: case ActionThrowRock:
if q.badger { dice := q.effect.count()
// At EACH active pet, the owner's own included. if q.unit != nil {
dice = effectCount(q.effect, sides[q.seat], q.unit)
}
if q.everyone {
for seat := range sides { for seat := range sides {
if throwRocks(q.seat, seat, q.effect.count()) { if throwRocks(q.seat, seat, dice) {
rockDeath = true anyDeath = true
} }
} }
} else { } else if t := nextTarget(q.seat); t >= 0 {
target := -1 if throwRocks(q.seat, t, dice) {
for off := 1; off < n; off++ { anyDeath = true
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: case ActionStripFoods:
// Skip if the eater already died (e.g. to Badger rocks). t := nextTarget(q.seat)
if sides[q.seat].unit != q.unit { if t < 0 {
continue continue
} }
count := q.effect.count() tu := sides[t].unit
if q.effect.Per == PerFaintedBees { tu.Bonus -= appleCount(tu.Foods)
count *= sides[q.seat].beesFainted 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}
+209 -36
View File
@@ -23,9 +23,12 @@ const (
// Food identifiers. // Food identifiers.
const ( 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.
+49
View File
@@ -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
View File
@@ -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,
+3 -1
View File
@@ -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]
+545
View File
@@ -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)
}
}
+92 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }