Improve UI of food and set-aside cards.

This commit is contained in:
Greyson Parrelli
2026-07-23 11:12:47 -04:00
parent 7bb20e5d2d
commit 235b1d9cc8
6 changed files with 323 additions and 94 deletions
+135 -38
View File
@@ -9,7 +9,27 @@ type BattleUnit struct {
Foods []Card `json:"foods,omitempty"`
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)
// Shields are this pet's own full-hit blocks, newest last. Each carries
// its source so the log can name it (Gorilla's innate block, or a Melon
// perk) and the client can drop the spent card.
Shields []shieldCharge
}
// shieldCharge is one full-hit block a pet carries. source is the granting
// item's display name (e.g. "Melon"); an empty source is the pet's own innate
// shield (Gorilla). card, when set, is the food to remove from the play area
// once the charge is spent.
type shieldCharge struct {
source string
card *Card
}
// shieldBlock describes a hit that a shield just absorbed, for the caller to
// narrate and clean up: source names the shield (empty = innate), and release
// is the field card to remove — a Melon food or a set-aside Turtle — or nil.
type shieldBlock struct {
source string
release *Card
}
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
@@ -83,6 +103,11 @@ type BattleEvent struct {
// "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.
// "setaside": Card (a fainted pet) enters Seat's set-aside zone with a
// pending effect (Blowfish, Badger, Snake, Crocodile, Turtle,
// Turkey, Mammoth).
// "release": a set-aside Card left Seat's zone, its effect consumed
// (matched by Card.ID).
Type string `json:"type"`
// Seat/Target must NOT be omitempty: 0 is a valid seat (the first
// player) and dropping it makes the client read sides[undefined].
@@ -125,6 +150,14 @@ type BattleResult struct {
type setAsideRocks struct {
dice int
everyone bool // Badger: hits every active pet, the owner's own included
src Card // the fainted pet, for the set-aside display
}
// lastPetVolley is Crocodile's set-aside: rocks that fire when the enemy plays
// the last pet in their deck.
type lastPetVolley struct {
dice int
src Card // the fainted pet, for the set-aside display
}
// battleSide is one seat's live state during the simulation.
@@ -140,7 +173,8 @@ type battleSide struct {
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
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
shieldCards []Card // Turtle set-aside cards, parallel to shields
}
// hasPetInStack reports whether any pet remains face-down in the stack.
@@ -158,7 +192,8 @@ type queuedPlay struct {
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)
everyone bool // rock volley hits every active pet (Badger)
release *Card // set-aside card to release once this play resolves (Blowfish/Badger/Croc)
}
// effectCount resolves an effect's final count: base × Per statistic,
@@ -266,24 +301,49 @@ func (g *Game) resolveBattle() {
}
// 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) {
// whole hit, Garlic shaves per-attack damage. Returns the damage dealt and,
// when a shield absorbed the hit, a shieldBlock describing it (nil
// otherwise). A set-aside Turtle shield is spent before the pet's own
// shields (Melon/Gorilla) so the borrowed card clears the board first.
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock) {
u := sides[seat].unit
if u == nil || amount <= 0 {
return 0, false
}
if u.Shield > 0 {
u.Shield--
return 0, true
return 0, nil
}
if sides[seat].shields > 0 {
sides[seat].shields--
return 0, true
var card *Card
source := ""
if n := len(sides[seat].shieldCards); n > 0 {
c := sides[seat].shieldCards[n-1]
sides[seat].shieldCards = sides[seat].shieldCards[:n-1]
card = &c
source = c.Name
}
return 0, &shieldBlock{source: source, release: card}
}
if n := len(u.Shields); n > 0 {
sc := u.Shields[n-1]
u.Shields = u.Shields[:n-1]
return 0, &shieldBlock{source: sc.source, release: sc.card}
}
dealt = max(0, amount-u.prevention())
u.Damage += dealt
return dealt, false
return dealt, nil
}
// emitShield narrates a blocked hit — naming the source rather than a bare
// "shield" — and removes the spent card (Melon food or set-aside Turtle)
// from the play area.
emitShield := func(seat int, petName string, block *shieldBlock) {
txt := fmt.Sprintf("%s blocks the hit.", petName)
if block.source != "" {
txt = fmt.Sprintf("%s blocks the hit with a %s.", petName, block.source)
}
emit(BattleEvent{Type: "shield", Seat: seat, Text: txt})
if block.release != nil {
emit(BattleEvent{Type: "release", Seat: seat, Card: block.release})
}
}
// faint fires the unit's faint effects (its own and its perk's) in
@@ -296,6 +356,13 @@ func (g *Game) resolveBattle() {
if isBee(u.Card) {
s.beesFainted++
}
// setAside marks the fainted pet as kept beside the arena with a
// pending effect, so the client can show its card until it resolves.
setAside := func() {
c := u.Card
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
Text: fmt.Sprintf("%s is set aside.", c.Name)})
}
for _, e := range u.effects() {
if e.Trigger != TriggerFaint || !allowed(e, u) {
continue
@@ -319,17 +386,24 @@ func (g *Game) resolveBattle() {
}
case ActionDelayedRocks:
s.oneShotRocks = append(s.oneShotRocks,
setAsideRocks{dice: e.count(), everyone: e.Target == "all"})
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
setAside()
case ActionRecurringRocks:
s.recurringRocks = append(s.recurringRocks, e.count())
setAside()
case ActionEnemyLastPetRocks:
s.lastPetRocks = append(s.lastPetRocks, e.count())
s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card})
setAside()
case ActionShieldNext:
s.shields += e.count()
s.shieldCards = append(s.shieldCards, u.Card)
setAside()
case ActionBeeAura:
s.beeBonus += e.count()
setAside()
case ActionPetAura:
s.petBonus += e.count()
setAside()
}
}
// Enemy Faints triggers on surviving pets elsewhere (Hippo).
@@ -373,7 +447,10 @@ func (g *Game) resolveBattle() {
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
}
case ActionShieldSelf:
u.Shield += e.count()
// Gorilla's own hurt-triggered block: innate, no card to drop.
for range e.count() {
u.Shields = append(u.Shields, shieldCharge{})
}
}
}
}
@@ -391,7 +468,7 @@ func (g *Game) resolveBattle() {
faces[i] = g.rollRockDie()
roll += faces[i]
}
dealt, blocked := hitUnit(target, roll)
dealt, block := hitUnit(target, roll)
died := !tu.Alive()
thrower := pname(from)
if su := sides[from].unit; su != nil {
@@ -410,9 +487,8 @@ func (g *Game) resolveBattle() {
Type: "rock", Seat: from, Target: target, Roll: roll, Dice: faces,
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
})
if blocked {
emit(BattleEvent{Type: "shield", Seat: target,
Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)})
if block != nil {
emitShield(target, tu.Card.Name, block)
}
if died {
faint(target, tu)
@@ -483,26 +559,45 @@ func (g *Game) resolveBattle() {
// Set-aside payouts fire before the new pet's own play
// effects.
for _, r := range s.oneShotRocks {
src := r.src
plays = append(plays, queuedPlay{seat: seat, everyone: r.everyone,
effect: Effect{Action: ActionThrowRock, Count: r.dice}})
effect: Effect{Action: ActionThrowRock, Count: r.dice}, release: &src})
}
s.oneShotRocks = nil
for _, dice := range s.recurringRocks {
plays = append(plays, queuedPlay{seat: seat,
effect: Effect{Action: ActionThrowRock, Count: dice}})
}
for _, e := range u.effects() {
// Walk the pet's own play effects, then its active perk's, so a
// play-time shield knows its source (an innate pet block vs a
// Melon perk that should be shown and later dropped).
addPlay := func(e Effect, perk *Card) {
if e.Trigger != TriggerPlay {
continue
return
}
// Shields apply the instant the pet enters play, ahead
// of any queued rocks (Melon).
// Shields apply the instant the pet enters play, ahead of
// any queued rocks (Melon).
if e.Action == ActionShieldSelf {
u.Shield += e.count()
continue
for range e.count() {
ch := shieldCharge{}
if perk != nil {
food := *perk
ch = shieldCharge{source: perk.Name, card: &food}
}
u.Shields = append(u.Shields, ch)
}
return
}
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
}
for _, e := range u.Card.Effects {
addPlay(e, nil)
}
if perk := u.activePerk(); perk != nil {
for _, e := range perk.Effects {
addPlay(e, perk)
}
}
}
}
// Cross-side play triggers: Rhino rocks anyone who just played;
@@ -524,9 +619,10 @@ func (g *Game) resolveBattle() {
}
}
if !sides[seat].hasPetInStack() {
for _, dice := range os.lastPetRocks {
for _, v := range os.lastPetRocks {
src := v.src
plays = append(plays, queuedPlay{seat: other,
effect: Effect{Action: ActionThrowRock, Count: dice}})
effect: Effect{Action: ActionThrowRock, Count: v.dice}, release: &src})
}
os.lastPetRocks = nil
}
@@ -582,6 +678,9 @@ func (g *Game) resolveBattle() {
anyDeath = true
}
}
if q.release != nil {
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
}
case ActionStripFoods:
t := nextTarget(q.seat)
if t < 0 {
@@ -665,8 +764,8 @@ func (g *Game) resolveBattle() {
// (the surrounding state is already per-seat).
ua, ub := sides[0].unit, sides[1].unit
powA, powB := ua.Power(), ub.Power()
dealtA, blockedA := hitUnit(0, powB)
dealtB, blockedB := hitUnit(1, powA)
dealtA, blockA := hitUnit(0, powB)
dealtB, blockB := hitUnit(1, powA)
// Scorpion: a clash attack that hurts, KOs.
if dealtA > 0 && ub.hasKnockout() {
ua.Damage = max(ua.Damage, ua.Power())
@@ -706,15 +805,13 @@ func (g *Game) resolveBattle() {
Died: []bool{!ua.Alive(), !ub.Alive()},
Text: clashTxt,
})
if blockedA {
emit(BattleEvent{Type: "shield", Seat: 0,
Text: fmt.Sprintf("%s blocks the hit with a shield.", ua.Card.Name)})
if blockA != nil {
emitShield(0, ua.Card.Name, blockA)
}
if blockedB {
emit(BattleEvent{Type: "shield", Seat: 1,
Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)})
if blockB != nil {
emitShield(1, ub.Card.Name, blockB)
}
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB {
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil {
break // stalemate: nothing can ever change
}
dealt := []int{dealtA, dealtB}
+86 -1
View File
@@ -1,6 +1,9 @@
package game
import "testing"
import (
"strings"
"testing"
)
// --- Tier 4 ---
@@ -165,6 +168,42 @@ func TestBlowfishDelayedRocksEnemyOnly(t *testing.T) {
}
}
// A fainting Blowfish is set aside as a card (with its own ID), then released
// once the next pet plays and its volley resolves.
func TestBlowfishSetAsideThenRelease(t *testing.T) {
g, _, _ := testGame(t)
g.RollDie = func() int { return 1 }
blowfish := g.realPet(t, "Blowfish")
res := forceBattle(t, g,
[]Card{blowfish, g.pet("Next", 2)},
[]Card{g.pet("Tank", 6)},
)
setasides := eventsOfType(res, "setaside")
if len(setasides) != 1 || setasides[0].Seat != 0 || setasides[0].Card == nil ||
setasides[0].Card.ID != blowfish.ID {
t.Fatalf("blowfish should be set aside once for seat 0: %+v", setasides)
}
releases := eventsOfType(res, "release")
if len(releases) != 1 || releases[0].Seat != 0 || releases[0].Card == nil ||
releases[0].Card.ID != blowfish.ID {
t.Fatalf("blowfish should be released once for seat 0: %+v", releases)
}
// setaside on faint, then release after the volley: setaside precedes the
// rock, which precedes the release.
idx := func(typ string) int {
for i, ev := range res.Events {
if ev.Type == typ {
return i
}
}
return -1
}
if !(idx("setaside") < idx("rock") && idx("rock") < idx("release")) {
t.Fatalf("expected setaside < rock < release, got %d < %d < %d",
idx("setaside"), idx("rock"), idx("release"))
}
}
// Skunk strips the enemy pet's foods; a wounded veteran can faint from the
// power loss.
func TestSkunkStripCanKill(t *testing.T) {
@@ -441,6 +480,52 @@ func TestMelonBlocksFirstHit(t *testing.T) {
}
}
// A used Melon names itself in the log and is released from the play area so
// the fan clears.
func TestMelonBlockNamesSourceAndClears(t *testing.T) {
g, _, _ := testGame(t)
melon := g.realFood(t, "Melon")
res := forceBattle(t, g,
[]Card{melon, g.pet("Holder", 3)},
[]Card{g.pet("Big", 5)},
)
shields := eventsOfType(res, "shield")
if len(shields) != 1 || !strings.Contains(shields[0].Text, "Melon") ||
strings.Contains(shields[0].Text, "shield") {
t.Fatalf("shield line should name the Melon, not say 'shield': %q", shields[0].Text)
}
releases := eventsOfType(res, "release")
if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != melon.ID {
t.Fatalf("the spent Melon should be released from the play area: %+v", releases)
}
}
// With both a set-aside Turtle and a Melon protecting the same pet, the
// borrowed Turtle shield is spent first so its card clears the board.
func TestTurtleShieldSpentBeforeMelon(t *testing.T) {
g, _, _ := testGame(t)
turtle := g.realPet(t, "Turtle") // power 2
res := forceBattle(t, g,
[]Card{turtle, g.realFood(t, "Melon"), g.pet("Holder", 3)},
[]Card{g.pet("Big", 5)},
)
// Turtle (2) dies to Big (5), setting aside a shield; Big keeps 2 damage.
// Holder plays with Melon (own shield). The next hit is absorbed by the
// Turtle set-aside first, releasing the Turtle; Holder then downs Big and
// its Melon shield is never spent.
releases := eventsOfType(res, "release")
if len(releases) != 1 || releases[0].Card == nil || releases[0].Card.ID != turtle.ID {
t.Fatalf("the Turtle shield should be spent (and released) before the Melon: %+v", releases)
}
shields := eventsOfType(res, "shield")
if len(shields) != 1 || !strings.Contains(shields[0].Text, "Turtle") {
t.Fatalf("the block should be credited to the Turtle: %+v", shields)
}
if res.WinnerSeat != 0 {
t.Fatalf("seat 0 should win with the Melon still in reserve, got %d", res.WinnerSeat)
}
}
// Leopard throws rocks equal to its power, apples included.
func TestLeopardRocksEqualPower(t *testing.T) {
g, _, _ := testGame(t)