From 10ac457e2e5912ab8a89b83c3135f29c7277274e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 23 Jul 2026 00:10:06 -0400 Subject: [PATCH] Add pets for tiers 4-6. --- README.md | 37 +- internal/game/battle.go | 453 +++++++++++++++++++----- internal/game/cards.go | 245 +++++++++++-- internal/game/fuzz_test.go | 49 +++ internal/game/game.go | 22 +- internal/game/game_test.go | 4 +- internal/game/tier456_test.go | 545 +++++++++++++++++++++++++++++ web/src/components/BattlePhase.tsx | 104 +++++- web/src/petArt.ts | 9 +- web/src/styles.css | 11 +- web/src/types.ts | 18 +- 11 files changed, 1334 insertions(+), 163 deletions(-) create mode 100644 internal/game/fuzz_test.go create mode 100644 internal/game/tier456_test.go diff --git a/README.md b/README.md index 88eed6c..da4c96c 100644 --- a/README.md +++ b/README.md @@ -60,23 +60,34 @@ Six rounds, each with its own shop tier deck. Per round: its last-applied **perk** (e.g. Honey, Garlic). 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 - with faces 0/0/1/1/2/2 and hit the opposing pet before the clash). The two - pets simultaneously deal their full power to each other as damage markers; - a pet with markers ≥ power faints (attack never drops while wounded). - Faint effects can push cards (apples, bees) onto either deck, recycle a - pet's apples (Dodo), or set aside delayed rocks that hit BOTH active pets - (Badger); survivors that took damage fire Hurt effects. Garlic prevents 1 - damage from every attack that hits its pet — a clash that damages no one - 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. + with faces 0/0/1/1/2/2 and hit the opposing pet before the clash; Skunk + strips foods; Wolverine steals apples; Chili mills the enemy deck). The + two pets simultaneously deal their full power to each other as damage + markers; a pet with markers ≥ power faints (attack never drops while + wounded). Faint effects push summons onto either deck, recycle apples + (Dodo), set aside delayed/recurring/conditional rock volleys (Badger, + Blowfish, Snake, Crocodile), raise auras (Turkey, Mammoth), or arm a + team shield (Turtle). Survivors that took damage fire Hurt effects + (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. Most trophies after round 6 wins. -Tiers 1–3 use real card data (T1: Ant, Cricket, Duck, Otter, Mosquito, Fish; -T2: Worm, Flamingo, Peacock, Swan, Rat, Spider + Honey; T3: Dog, Dolphin, -Giraffe, Camel, Sheep, Dodo, Badger + Garlic). Tiers 4–6 are effect-less -placeholders awaiting their real definitions. +All six tiers use the real card data: + +| Tier | Pets | Food | +| ---- | ---- | ---- | +| 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 diff --git a/internal/game/battle.go b/internal/game/battle.go index 37b869a..3139f27 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -5,8 +5,9 @@ package game type BattleUnit struct { Card Card `json:"card"` 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 + Shield int `json:"shield"` // hits that will be fully prevented (Gorilla, Melon) } func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus } @@ -43,15 +44,25 @@ func (u *BattleUnit) prevention() int { return total } -// takeHit applies one attack of the given strength and returns the damage -// actually dealt after prevention. -func (u *BattleUnit) takeHit(amount int) int { - if amount <= 0 { - return 0 +// hasKnockout reports whether the unit's clash attacks KO (Scorpion). +func (u *BattleUnit) hasKnockout() bool { + for _, e := range u.effects() { + if e.Trigger == TriggerPassive && e.Action == ActionKnockout { + return true + } } - dealt := max(0, amount-u.prevention()) - u.Damage += dealt - return dealt + return false +} + +// 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. @@ -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. type BattleEvent struct { + // "prep": Card starts the battle already in play for Seat (Monkey). // "reveal": Seat flipped Card off their stack (food or pet). // "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. // "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. + // "heal": Seat's pet healed; DamageAfter is its new damage total. Type string `json:"type"` Seat int `json:"seat,omitempty"` Target int `json:"target,omitempty"` Card *Card `json:"card,omitempty"` + Count int `json:"count,omitempty"` // clash: per-seat damage totals / deaths after the exchange. Damage []int `json:"damage,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"` DamageAfter int `json:"damageAfter,omitempty"` TargetDied bool `json:"targetDied,omitempty"` @@ -90,21 +108,64 @@ type BattleResult struct { 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. type battleSide struct { - stack []Card // remaining face-down cards, top first - pending []Card // foods revealed and waiting for a pet - unit *BattleUnit - beesFainted int // friendly bees fainted so far (Dog) - delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays + stack []Card // remaining face-down cards, top first + pending []Card // foods revealed (or prepped) waiting for a pet + unit *BattleUnit + beesFainted int // friendly bees fainted so far (Dog) + 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. type queuedPlay struct { - seat int - unit *BattleUnit // the pet whose play queued this (must still be in play for eats) - effect Effect - badger bool // delayedRocks payout: hits EVERY active pet, own included + seat int + unit *BattleUnit // the unit whose play queued this; nil for set-asides + effect Effect + 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, @@ -113,37 +174,100 @@ type queuedPlay struct { // 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 // the last-applied perk counts). If anyone can no longer field a pet the -// battle ends. Otherwise play effects resolve — rocks roll dice (faces -// 0,0,1,1,2,2) and can faint a pet before the clash. Then the two pets deal -// their full Power to each other simultaneously as damage markers; a pet -// with Damage >= Power faints, firing Faint effects. Survivors that took -// damage fire Hurt effects. Garlic prevents 1 damage from every attack that -// hits its pet; a clash that deals no damage to anyone ends the battle as a +// battle ends. Otherwise play effects resolve (rocks, strips, steals, +// mills — any of which can faint a pet before the clash). Then the two pets +// deal their full Power to each other simultaneously as damage markers; a +// pet with Damage >= Power faints, firing Faint effects. Survivors that +// took damage fire Hurt effects. Shields (Turtle, Gorilla, Melon) block +// 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. func (g *Game) resolveBattle() { n := len(g.Players) res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)} sides := make([]*battleSide, n) + emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) } + 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) } - 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) { s := sides[seat] s.stack = append([]Card{c}, s.stack...) 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 - // effect order, and tracks fainted bees. - faint := func(seat int, u *BattleUnit) { + // effect order, updates faint counters, and notifies enemy pets + // (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) { - sides[seat].beesFainted++ + s.beesFainted++ } for _, e := range u.effects() { - if e.Trigger != TriggerFaint { + if e.Trigger != TriggerFaint || !allowed(e, u) { continue } switch e.Action { @@ -152,12 +276,8 @@ func (g *Game) resolveBattle() { if e.Target == "enemy" { target = (seat + 1) % n } - for range e.count() { - if e.Card == "bee" { - summon(target, g.newBee()) - } else { - summon(target, g.newApple()) - } + for range effectCount(e, s, u) { + summon(target, mintFor(e.Card)) } case ActionRecycleApples: recycled := 0 @@ -168,7 +288,34 @@ func (g *Game) resolveBattle() { } } 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 } for _, e := range u.effects() { - if e.Trigger != TriggerHurt { + if e.Trigger != TriggerHurt || !allowed(e, u) { continue } switch e.Action { case ActionEatApple: - for range e.count() { + for range effectCount(e, sides[seat], u) { u.Foods = append(u.Foods, g.newApple()) u.Bonus++ } emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus}) case ActionSummonTop: - for range e.count() { - if e.Card == "bee" { - summon(seat, g.newBee()) - } else { - summon(seat, g.newApple()) - } + for range effectCount(e, sides[seat], u) { + summon(seat, mintFor(e.Card)) } + case ActionShieldSelf: + u.Shield += e.count() } } } - // throwRocks rolls `dice` rock dice against one target's pet, handling - // prevention, death (with faint), and hurt. Reports whether it killed. + // throwRocks rolls `dice` rock dice against one seat's pet. Rocks are + // not "attacks with" the pet, so no knockout applies. Reports a kill. throwRocks := func(from, target, dice int) (killed bool) { tu := sides[target].unit if tu == nil { @@ -212,12 +357,15 @@ func (g *Game) resolveBattle() { for range dice { roll += g.rollRockDie() } - dealt := tu.takeHit(roll) + dealt, blocked := hitUnit(target, roll) died := !tu.Alive() emit(BattleEvent{ Type: "rock", Seat: from, Target: target, Roll: roll, DamageAfter: tu.Damage, TargetDied: died, }) + if blocked { + emit(BattleEvent{Type: "shield", Seat: target}) + } if died { faint(target, tu) sides[target].unit = nil @@ -229,41 +377,95 @@ func (g *Game) resolveBattle() { 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 - // is a detected stalemate, and summons are finite. The guard is just - // insurance as effects get richer. + // is a detected stalemate, and summons/auras are finite. The guard is + // just insurance as effects get richer. for range 10_000 { // Reveal until every side has a pet in play or runs out. Play // effects queue up and resolve after all reveals (simultaneous). var plays []queuedPlay + newlyPlayed := make([]bool, n) for seat, s := range sides { for s.unit == nil && len(s.stack) > 0 { c := s.stack[0] s.stack = s.stack[1:] - emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c}) if c.IsFood() { + emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c}) s.pending = append(s.pending, c) continue } u := &BattleUnit{Card: c, Foods: s.pending} - for _, f := range s.pending { - if f.Food == FoodApple { - u.Bonus++ - } + u.Bonus += appleCount(s.pending) + u.Bonus += s.petBonus + 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.unit = u - // Set-aside Badgers pay out when the next pet plays, - // before that pet's own play effects. - for _, dice := range s.delayedRocks { - plays = append(plays, queuedPlay{seat: seat, unit: u, badger: true, + newlyPlayed[seat] = true + // Set-aside payouts fire before the new pet's own play + // effects. + 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}}) } - s.delayedRocks = nil for _, e := range u.effects() { - if e.Trigger == TriggerPlay { - plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e}) + 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}) + } + } + } + // 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 } - // Resolve play effects. Rocks land before the clash and can faint - // pets outright. - rockDeath := false + // Resolve play effects. Any of these can faint a pet before the + // clash. + anyDeath := false 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 { case ActionThrowRock: - if q.badger { - // At EACH active pet, the owner's own included. + 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, q.effect.count()) { - rockDeath = true + if throwRocks(q.seat, seat, dice) { + anyDeath = 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 + } else if t := nextTarget(q.seat); t >= 0 { + if throwRocks(q.seat, t, dice) { + anyDeath = true } } - case ActionEatApple: - // Skip if the eater already died (e.g. to Badger rocks). - if sides[q.seat].unit != q.unit { + case ActionStripFoods: + t := nextTarget(q.seat) + if t < 0 { continue } - count := q.effect.count() - if q.effect.Per == PerFaintedBees { - count *= sides[q.seat].beesFainted + 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 { for range count { 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 } // Clash. Two-player for now; >2-player battle pairings come later // (the surrounding state is already per-seat). ua, ub := sides[0].unit, sides[1].unit - dealtA := ua.takeHit(ub.Power()) - dealtB := ub.takeHit(ua.Power()) + powA, powB := ua.Power(), ub.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{ Type: "clash", Damage: []int{ua.Damage, ub.Damage}, 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 } dealt := []int{dealtA, dealtB} diff --git a/internal/game/cards.go b/internal/game/cards.go index bb6f869..45a9601 100644 --- a/internal/game/cards.go +++ b/internal/game/cards.go @@ -23,9 +23,12 @@ const ( // Food identifiers. const ( - FoodApple = "apple" - FoodHoney = "honey" - FoodGarlic = "garlic" + FoodApple = "apple" + FoodHoney = "honey" + FoodGarlic = "garlic" + FoodPineapple = "pineapple" + FoodChili = "chili" + FoodMelon = "melon" ) // EffectTrigger is when an effect fires. @@ -41,6 +44,10 @@ const ( // TriggerBattlePrep fires right after the shop phase ends, as players // begin ordering their decks. 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 // prevention); they're consulted contextually rather than fired. TriggerPassive EffectTrigger = "passive" @@ -73,13 +80,65 @@ const ( // apples back on top of its owner's deck. ActionRecycleApples EffectAction = "recycleApples" // ActionDelayedRocks (faint) sets the pet aside: when its owner plays - // their next pet, it throws Count rocks at EACH active pet — the - // enemy's and the owner's own. + // their next pet, it throws Count rocks. With Target "all" (Badger) + // the volley hits EACH active pet — the owner's own included; + // otherwise it targets the enemy pet as usual (Blowfish). 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. -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. type Effect struct { @@ -90,6 +149,10 @@ type Effect struct { Target string `json:"target,omitempty"` // summonTop: "" (self) | "enemy" // Per multiplies Count by a battle statistic (e.g. PerFaintedBees). 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 int `json:"minRound,omitempty"` } @@ -148,10 +211,7 @@ type foodTemplate struct { } // petTiers defines the shop decks' pets. Index 0 is tier 1 (round 1) -// through index 5 for tier 6 (round 6). -// -// Tiers 1-2 are real card data. Tiers 3-6 are placeholders with the same -// structure until their real definitions are provided. +// through index 5 for tier 6 (round 6). All six tiers are real card data. var petTiers = [MaxRounds][]petTemplate{ { // Tier 1 { @@ -253,16 +313,127 @@ var petTiers = [MaxRounds][]petTemplate{ }, { 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", }, }, - placeholderTier([6]string{"Skunk", "Hippo", "Bison", "Deer", "Squirrel", "Whale"}, - [6]int{5, 6, 6, 4, 4, 5}), - placeholderTier([6]string{"Scorpion", "Rhino", "Monkey", "Cow", "Seal", "Shark"}, - [6]int{5, 7, 6, 6, 6, 7}), - placeholderTier([6]string{"Leopard", "Boar", "Gorilla", "Mammoth", "Snake", "Tiger"}, - [6]int{8, 9, 9, 10, 8, 9}), + { // Tier 4 + { + Name: "Squirrel", Power: 2, Suits: []Suit{SuitRed, SuitBlue}, + Effects: []Effect{ + {Trigger: TriggerBuy, Action: ActionGainApple, Count: 2}, + {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. @@ -282,25 +453,27 @@ var foodTiers = [MaxRounds][]foodTemplate{ EffectText: "Every attack that hits this pet deals 1 less damage", }, }, - {}, {}, {}, -} - -// placeholderTier mirrors tier 1's suit distribution (each suit appears on 4 -// of the 12 cards) for pets whose real definitions aren't in yet. -func placeholderTier(names [6]string, powers [6]int) []petTemplate { - suitPairs := [6][]Suit{ - {SuitBlue, SuitYellow}, - {SuitRed, SuitBlue}, - {SuitYellow, SuitBlue}, - {SuitYellow, SuitRed}, - {SuitRed, SuitBlue}, - {SuitYellow, SuitRed}, - } - tier := make([]petTemplate, 6) - for i := range names { - tier[i] = petTemplate{Name: names[i], Power: powers[i], Suits: suitPairs[i]} - } - return tier + { // Tier 4 + { + Name: "Pineapple", Food: FoodPineapple, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionThrowRock, Count: 3}}, + EffectText: "Play: throw 3 Rocks", + }, + }, + { // Tier 5 + { + Name: "Chili", Food: FoodChili, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionMillEnemy}}, + EffectText: "Play: discard cards from the top of the enemy deck until a non-Bee pet is showing", + }, + }, + { // Tier 6 + { + Name: "Melon", Food: FoodMelon, Copies: 2, Perk: true, + Effects: []Effect{{Trigger: TriggerPlay, Action: ActionShieldSelf}}, + EffectText: "The first time this pet is hit, prevent all damage", + }, + }, } // newCardID mints a unique card ID within the game. diff --git a/internal/game/fuzz_test.go b/internal/game/fuzz_test.go new file mode 100644 index 0000000..43de896 --- /dev/null +++ b/internal/game/fuzz_test.go @@ -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) + } + } +} diff --git a/internal/game/game.go b/internal/game/game.go index c742a43..a0862d2 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -45,6 +45,9 @@ type Player struct { Trophies int `json:"trophies"` Ready bool `json:"ready"` // arrange submitted / battle acknowledged 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. @@ -207,6 +210,7 @@ func (g *Game) startShopRound() { for _, p := range g.Players { p.Coins = CoinsPerRound p.Ready = false + p.TripledThisRound = false } g.ShopRow = make([]Card, ShopRowSize) for i := range g.ShopRow { @@ -285,12 +289,17 @@ func (g *Game) Sell(playerID string, cardIDs []string) error { 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) { for _, e := range c.Effects { if e.Trigger != trigger || g.Round < e.MinRound { continue } + if e.Condition == ConditionTripled && !p.TripledThisRound { + continue + } switch e.Action { case ActionGainApple: for range e.count() { @@ -298,6 +307,16 @@ func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) { } case ActionRefreshGold: 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.Coins-- + p.TripledThisRound = true g.Pending = &PendingTrade{ PlayerID: playerID, Tier: nextTier, diff --git a/internal/game/game_test.go b/internal/game/game_test.go index a8e5e86..e1ecda4 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -1,6 +1,7 @@ package game import ( + "slices" "testing" ) @@ -171,7 +172,8 @@ func TestTradeInThreeMatchingSuits(t *testing.T) { if err := g.TradeChoose(p.ID, 0); err != nil { 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") } deck2 := g.ShopDecks[1] diff --git a/internal/game/tier456_test.go b/internal/game/tier456_test.go new file mode 100644 index 0000000..86282ab --- /dev/null +++ b/internal/game/tier456_test.go @@ -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) + } +} diff --git a/web/src/components/BattlePhase.tsx b/web/src/components/BattlePhase.tsx index 24709cc..8c94ffe 100644 --- a/web/src/components/BattlePhase.tsx +++ b/web/src/components/BattlePhase.tsx @@ -18,19 +18,27 @@ interface UnitVis { interface SideVis { stack: number - pending: Card[] // revealed foods waiting for a pet + pending: Card[] // foods revealed (or prepped) waiting for a pet unit: UnitVis | null } // Milliseconds each event type stays on screen during playback. const EVENT_MS: Record = { + prep: 700, reveal: 800, summon: 1000, + mill: 700, rock: 1200, clash: 1400, + shield: 900, + strip: 1100, + steal: 1100, 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 // seat's visual state. Units that died in the last applied event are still // 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] switch (ev.type) { + case 'prep': + sides[ev.seat!].pending.push(ev.card!) + break case 'reveal': { const s = sides[ev.seat!] s.stack-- @@ -52,7 +63,7 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side s.unit = { card, foods: s.pending, - bonus: s.pending.filter((f) => f.food === 'apple').length, + bonus: ev.bonus ?? appleCount(s.pending), damage: 0, dying: false, } @@ -63,6 +74,9 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side case 'summon': sides[ev.seat!].stack++ break + case 'mill': + sides[ev.seat!].stack-- + break case 'rock': { const u = sides[ev.target!].unit if (u) { @@ -78,16 +92,81 @@ function replay(events: BattleEvent[], stackSizes: number[], upto: number): Side if (ev.died?.[seat]) s.unit.dying = true }) 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': { const u = sides[ev.seat!].unit if (u) u.bonus = ev.bonus ?? u.bonus 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 } +// 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 // fly, pets clash, the fallen fade out, then the round result lands. 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 clashDying = lastEvent?.type === 'clash' && s.unit?.dying 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 milling = !done && lastEvent?.type === 'mill' && lastEvent.seat === seat const revealing = !done && lastEvent?.type === 'reveal' && lastEvent.seat === seat + const pop = done ? null : unitPop(lastEvent, seat, events, step) const stackEl = (
@@ -140,6 +220,11 @@ export function BattlePhase({ view, send }: Props) {
)} + {milling && lastEvent?.card && ( +
+ +
+ )} ) @@ -182,16 +267,11 @@ export function BattlePhase({ view, send }: Props) { ))} )} - {(lastEvent?.type === 'clash' || rockVictim) && !done && ( -
- {lastEvent?.type === 'rock' - ? lastEvent.roll === 0 - ? 'miss!' - : `−${lastEvent.roll}` - : `−${clashDamageTaken(events, step - 1, seat)}`} + {pop && ( +
+ {pop}
)} - {eating &&
🍎 +1
}
)} @@ -284,7 +364,7 @@ function clashDamageTaken(events: BattleEvent[], idx: number, seat: number): num for (let k = idx - 1; k >= 0; k--) { const e = events[k] 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 break } diff --git a/web/src/petArt.ts b/web/src/petArt.ts index 966acf4..352228c 100644 --- a/web/src/petArt.ts +++ b/web/src/petArt.ts @@ -6,16 +6,19 @@ const PET_EMOJI: Record = { // Tier 3 Dog: '🐶', Dolphin: '🐬', Giraffe: '🦒', Camel: '🐫', Sheep: '🐑', Dodo: '🦤', Badger: '🦡', // Tier 4 - Skunk: '🦨', Hippo: '🦛', Bison: '🦬', Deer: '🦌', Squirrel: '🐿️', Whale: '🐳', + Squirrel: '🐿️', Turtle: '🐢', Rooster: '🐓', Bison: '🦬', Blowfish: '🐡', Skunk: '🦨', Hippo: '🦛', // Tier 5 - Scorpion: '🦂', Rhino: '🦏', Monkey: '🐒', Cow: '🐄', Seal: '🦭', Shark: '🦈', + Monkey: '🐒', Rhino: '🦏', Crocodile: '🐊', Scorpion: '🦂', Seal: '🦭', Shark: '🦈', Turkey: '🦃', // Tier 6 - Leopard: '🐆', Boar: '🐗', Gorilla: '🦍', Mammoth: '🦣', Snake: '🐍', Tiger: '🐯', + Gorilla: '🦍', Fly: '🪰', Leopard: '🐆', Mammoth: '🦣', Cat: '🐱', Snake: '🐍', Wolverine: '🐺', // Summons & foods Bee: '🐝', Apple: '🍎', Honey: '🍯', Garlic: '🧄', + Pineapple: '🍍', + Chili: '🌶️', + Melon: '🍈', } export function artFor(name: string): string { diff --git a/web/src/styles.css b/web/src/styles.css index 801d560..56bc34f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -931,7 +931,7 @@ h3 { animation: rock-fly-left 700ms ease-in forwards; } -@keyframes eat-pop { +@keyframes fx-pop { 0% { opacity: 0; transform: translate(-50%, 6px) scale(0.6); @@ -946,7 +946,7 @@ h3 { } } -.eat-pop { +.fx-pop { position: absolute; top: 0; left: 50%; @@ -954,9 +954,14 @@ h3 { font-size: 1rem; color: #7be495; 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; z-index: 5; + white-space: nowrap; +} + +.mill-pop .card { + filter: grayscale(1); } @keyframes clash-left { diff --git a/web/src/types.ts b/web/src/types.ts index 808c396..77781d7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -38,18 +38,30 @@ export interface PendingTrade { } export interface BattleEvent { - type: 'reveal' | 'summon' | 'rock' | 'clash' | 'eat' + type: + | 'prep' + | 'reveal' + | 'summon' + | 'mill' + | 'rock' + | 'clash' + | 'shield' + | 'strip' + | 'steal' + | 'eat' + | 'heal' seat?: number target?: number card?: Card + count?: number // clash damage?: number[] died?: boolean[] - // rock + // rock / strip / steal roll?: number damageAfter?: number targetDied?: boolean - // eat + // eat (new bonus total) / reveal (starting bonus) bonus?: number }