Initial pass at Golden Pack.
This commit is contained in:
+507
-68
@@ -1,6 +1,9 @@
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// BattleUnit is a pet in play with its attached foods applied. Power
|
||||
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||||
@@ -13,6 +16,11 @@ type BattleUnit struct {
|
||||
// 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
|
||||
// afterAttackUsed gates once-per-battle After-Attack effects (Bulldog).
|
||||
afterAttackUsed bool
|
||||
// hitPrevent are this pet's own one-shot partial damage preventions
|
||||
// (Potato perk: two charges of 2). Consumed before any side-level charge.
|
||||
hitPrevent []int
|
||||
}
|
||||
|
||||
// shieldCharge is one full-hit block a pet carries. source is the granting
|
||||
@@ -32,6 +40,15 @@ type shieldBlock struct {
|
||||
release *Card
|
||||
}
|
||||
|
||||
// preventInfo describes a partial damage prevention a hit just consumed (Cone
|
||||
// Snail): amount is the damage shaved off, release is the set-aside card to
|
||||
// remove from the board. The caller narrates it after its own hit event so the
|
||||
// replay order stays correct.
|
||||
type preventInfo struct {
|
||||
amount int
|
||||
release *Card
|
||||
}
|
||||
|
||||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||||
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
||||
|
||||
@@ -160,6 +177,13 @@ type lastPetVolley struct {
|
||||
src Card // the fainted pet, for the set-aside display
|
||||
}
|
||||
|
||||
// feedAside is Giant Isopod's set-aside: each time the owner plays a pet, spend
|
||||
// one Trumpet to feed that pet `apples` apples.
|
||||
type feedAside struct {
|
||||
apples int
|
||||
src Card // the fainted pet, for the set-aside display
|
||||
}
|
||||
|
||||
// battleSide is one seat's live state during the simulation.
|
||||
type battleSide struct {
|
||||
stack []Card // remaining face-down cards, top first
|
||||
@@ -175,6 +199,17 @@ type battleSide struct {
|
||||
recurringRocks []setAsideRocks // Snake: on every own pet play
|
||||
lastPetRocks []lastPetVolley // Crocodile: when the enemy plays their last pet
|
||||
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
||||
|
||||
// --- Golden pack ---
|
||||
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
|
||||
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
|
||||
grSummoned bool // Golden Retriever already summoned this battle
|
||||
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
|
||||
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
|
||||
beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played
|
||||
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
|
||||
petsPlayed int // pets fielded so far (Komodo's "first pet")
|
||||
retrieverGuards []int // German Shepherd: damage the Golden Retriever prevents on its first hits
|
||||
}
|
||||
|
||||
// hasPetInStack reports whether any pet remains face-down in the stack.
|
||||
@@ -187,6 +222,17 @@ func (s *battleSide) hasPetInStack() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// canField reports whether the side has, or can still produce, a pet to fight:
|
||||
// one in play, a pet left in the stack, or a Golden Retriever waiting to be
|
||||
// summoned. Used to decide the battle is over only after play effects (and any
|
||||
// parting shots) have resolved.
|
||||
func (s *battleSide) canField() bool {
|
||||
if s.unit != nil || s.hasPetInStack() {
|
||||
return true
|
||||
}
|
||||
return len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned
|
||||
}
|
||||
|
||||
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
||||
type queuedPlay struct {
|
||||
seat int
|
||||
@@ -198,8 +244,9 @@ type queuedPlay struct {
|
||||
}
|
||||
|
||||
// effectCount resolves an effect's final count: base × Per statistic,
|
||||
// limited by Cap.
|
||||
func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
||||
// limited by Cap. enemy is the opposing side (for enemy-relative multipliers);
|
||||
// it may be nil when the effect has no such Per.
|
||||
func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int {
|
||||
n := e.count()
|
||||
switch e.Per {
|
||||
case PerFaintedBees:
|
||||
@@ -210,6 +257,14 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
||||
n *= appleCount(u.Foods)
|
||||
case PerPower:
|
||||
n *= u.Power()
|
||||
case PerUniqueFaintedHats:
|
||||
n *= len(s.faintedHats)
|
||||
case PerEnemyFaintedPets:
|
||||
if enemy != nil {
|
||||
n *= enemy.petsFainted
|
||||
} else {
|
||||
n = 0
|
||||
}
|
||||
}
|
||||
if e.Cap > 0 && n > e.Cap {
|
||||
n = e.Cap
|
||||
@@ -231,20 +286,72 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit) int {
|
||||
// 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.
|
||||
//
|
||||
// resolveBattle is the orchestrator: it re-runs the (deterministic) simulation
|
||||
// from the recorded dice/decision tapes, publishing either a completed result
|
||||
// or a suspended one awaiting a mid-battle decision (Golden pack: Nurse Shark).
|
||||
func (g *Game) resolveBattle() {
|
||||
g.NextCardID = g.BattleCardBase
|
||||
g.battleRollCursor = 0
|
||||
g.battleDecisionCursor = 0
|
||||
res, pending := g.runBattle()
|
||||
g.Battle = res
|
||||
if pending != nil {
|
||||
g.PendingBattle = pending
|
||||
return
|
||||
}
|
||||
g.PendingBattle = nil
|
||||
g.finalizeBattle(res)
|
||||
}
|
||||
|
||||
// finalizeBattle applies the persistent effects of a completed battle: trophies,
|
||||
// the priority token hand-off, the result log line, and clearing the per-round
|
||||
// apples-in-play bank. Deferred here (not inside runBattle) because runBattle
|
||||
// may re-run several times before the battle actually completes.
|
||||
func (g *Game) finalizeBattle(res *BattleResult) {
|
||||
n := len(g.Players)
|
||||
winner := res.WinnerSeat
|
||||
if winner >= 0 {
|
||||
g.Players[winner].Trophies += res.Trophies
|
||||
// Priority token: the winner hands it to the other player; a loser who
|
||||
// held it keeps it; a draw leaves it put. (Two-player rule.)
|
||||
if winner == g.PrioritySeat {
|
||||
g.PrioritySeat = (winner + 1) % n
|
||||
}
|
||||
}
|
||||
if winner < 0 {
|
||||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||||
} else {
|
||||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
p.PendingApplesInPlay = 0
|
||||
p.PendingTrumpets = 0
|
||||
}
|
||||
}
|
||||
|
||||
// runBattle plays the simulation to completion or until it needs a mid-battle
|
||||
// decision, returning the (partial) result and a non-nil pending in the latter
|
||||
// case. It mutates no persistent player state — that is finalizeBattle's job.
|
||||
func (g *Game) runBattle() (*BattleResult, *PendingBattleDecision) {
|
||||
n := len(g.Players)
|
||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
|
||||
var suspended *PendingBattleDecision
|
||||
sides := make([]*battleSide, n)
|
||||
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
||||
// pname is the owning player's display name for a seat, for log text.
|
||||
pname := func(seat int) string { return g.Players[seat].Name }
|
||||
|
||||
for _, p := range g.Players {
|
||||
s := &battleSide{stack: append([]Card(nil), p.Deck...)}
|
||||
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||||
sides[p.Seat] = s
|
||||
res.StackSizes[p.Seat] = len(p.Deck)
|
||||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||||
}
|
||||
// enemyOf returns the opposing side (two-player; generalizes later).
|
||||
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
|
||||
// seatOrder resolves the priority-token holder first, then everyone else.
|
||||
// Reveals, queued play effects, and cross-side triggers all follow it, so
|
||||
// when two pets would act simultaneously (e.g. both throwing rocks) the
|
||||
@@ -257,6 +364,13 @@ func (g *Game) resolveBattle() {
|
||||
seatOrder = append(seatOrder, seat)
|
||||
}
|
||||
}
|
||||
// startApple seeds one in-play apple onto a seat's first pet.
|
||||
startApple := func(seat int) {
|
||||
apple := g.newApple()
|
||||
sides[seat].pending = append(sides[seat].pending, apple)
|
||||
emit(BattleEvent{Type: "prep", Seat: seat, Card: &apple,
|
||||
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(seat))})
|
||||
}
|
||||
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||
// to the owner's first pet.
|
||||
for _, p := range g.Players {
|
||||
@@ -264,14 +378,23 @@ func (g *Game) resolveBattle() {
|
||||
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,
|
||||
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))})
|
||||
startApple(p.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||||
// round (read-only here; finalizeBattle clears it once the battle ends,
|
||||
// so re-runs bank the same amount).
|
||||
for range p.PendingApplesInPlay {
|
||||
startApple(p.Seat)
|
||||
}
|
||||
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||||
if p.PendingTrumpets > 0 {
|
||||
sides[p.Seat].trumpets += p.PendingTrumpets
|
||||
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
|
||||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||||
}
|
||||
}
|
||||
|
||||
summon := func(seat int, c Card, cause string) {
|
||||
@@ -280,12 +403,43 @@ func (g *Game) resolveBattle() {
|
||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||||
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
|
||||
}
|
||||
// summonBottom puts a card on the BOTTOM of a seat's stack (Bear).
|
||||
summonBottom := func(seat int, c Card, cause string) {
|
||||
s := sides[seat]
|
||||
s.stack = append(s.stack, c)
|
||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||||
Text: fmt.Sprintf("%s puts %s %s on the bottom of %s's deck.", cause, article(c.Name), c.Name, pname(seat))})
|
||||
}
|
||||
mintFor := func(kind string) Card {
|
||||
if kind == "bee" {
|
||||
return g.newBee()
|
||||
}
|
||||
return g.newApple()
|
||||
}
|
||||
// gainTrumpets adds trumpets to a side and narrates it.
|
||||
gainTrumpets := func(seat, n int, cause string) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
sides[seat].trumpets += n
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: n,
|
||||
Text: fmt.Sprintf("%s gains %d Trumpet%s.", cause, n, plural(n))})
|
||||
}
|
||||
// spend pays an effect's Trumpet cost from a side's pool, narrating the
|
||||
// spend. Returns false (without paying) when the side can't afford it, so
|
||||
// the caller skips the effect. A zero-cost effect always "pays".
|
||||
spend := func(seat int, e Effect, cause string) bool {
|
||||
if e.CostTrumpet <= 0 {
|
||||
return true
|
||||
}
|
||||
if sides[seat].trumpets < e.CostTrumpet {
|
||||
return false
|
||||
}
|
||||
sides[seat].trumpets -= e.CostTrumpet
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -e.CostTrumpet,
|
||||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", cause, e.CostTrumpet, plural(e.CostTrumpet))})
|
||||
return true
|
||||
}
|
||||
|
||||
// allowed gates battle-time effects on their conditions.
|
||||
allowed := func(e Effect, u *BattleUnit) bool {
|
||||
@@ -306,10 +460,10 @@ func (g *Game) resolveBattle() {
|
||||
// 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) {
|
||||
hitUnit := func(seat, amount int) (dealt int, block *shieldBlock, prevent *preventInfo) {
|
||||
u := sides[seat].unit
|
||||
if u == nil || amount <= 0 {
|
||||
return 0, nil
|
||||
return 0, nil, nil
|
||||
}
|
||||
if sides[seat].shields > 0 {
|
||||
sides[seat].shields--
|
||||
@@ -321,16 +475,36 @@ func (g *Game) resolveBattle() {
|
||||
card = &c
|
||||
source = c.Name
|
||||
}
|
||||
return 0, &shieldBlock{source: source, release: card}
|
||||
return 0, &shieldBlock{source: source, release: card}, nil
|
||||
}
|
||||
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}
|
||||
return 0, &shieldBlock{source: sc.source, release: sc.card}, nil
|
||||
}
|
||||
dealt = max(0, amount-u.prevention())
|
||||
reduce := u.prevention()
|
||||
// One partial-prevention charge per hit (whether or not it fully absorbs
|
||||
// the blow): the pet's own first (Potato), else a side-level set-aside
|
||||
// (Cone Snail).
|
||||
if len(u.hitPrevent) > 0 {
|
||||
amt := u.hitPrevent[0]
|
||||
u.hitPrevent = u.hitPrevent[1:]
|
||||
reduce += amt
|
||||
prevent = &preventInfo{amount: amt}
|
||||
} else if len(sides[seat].hitPrevent) > 0 {
|
||||
amt := sides[seat].hitPrevent[0]
|
||||
sides[seat].hitPrevent = sides[seat].hitPrevent[1:]
|
||||
reduce += amt
|
||||
prevent = &preventInfo{amount: amt}
|
||||
if len(sides[seat].preventCards) > 0 {
|
||||
c := sides[seat].preventCards[0]
|
||||
sides[seat].preventCards = sides[seat].preventCards[1:]
|
||||
prevent.release = &c
|
||||
}
|
||||
}
|
||||
dealt = max(0, amount-reduce)
|
||||
u.Damage += dealt
|
||||
return dealt, nil
|
||||
return dealt, nil, prevent
|
||||
}
|
||||
|
||||
// emitShield narrates a blocked hit — naming the source rather than a bare
|
||||
@@ -347,6 +521,21 @@ func (g *Game) resolveBattle() {
|
||||
}
|
||||
}
|
||||
|
||||
// emitPrevent narrates a Cone Snail partial prevention after the hit that
|
||||
// consumed it, and drops the spent set-aside card.
|
||||
emitPrevent := func(seat int, petName string, prev *preventInfo) {
|
||||
u := sides[seat].unit
|
||||
after := 0
|
||||
if u != nil {
|
||||
after = u.Damage
|
||||
}
|
||||
emit(BattleEvent{Type: "prevent", Seat: seat, Count: prev.amount, DamageAfter: after,
|
||||
Text: fmt.Sprintf("A set-aside Cone Snail shields %s, preventing %d damage.", petName, prev.amount)})
|
||||
if prev.release != nil {
|
||||
emit(BattleEvent{Type: "release", Seat: seat, Card: prev.release})
|
||||
}
|
||||
}
|
||||
|
||||
// faint fires the unit's faint effects (its own and its perk's) in
|
||||
// effect order, updates faint counters, and notifies enemy pets
|
||||
// (Hippo's heal).
|
||||
@@ -357,6 +546,11 @@ func (g *Game) resolveBattle() {
|
||||
if isBee(u.Card) {
|
||||
s.beesFainted++
|
||||
}
|
||||
// Track distinct suits among friendly fainted pets (Honduran White
|
||||
// Bat). Bees and the Golden Retriever have no suit.
|
||||
if !isBee(u.Card) && u.Card.Suit != "" {
|
||||
s.faintedHats[u.Card.Suit] = true
|
||||
}
|
||||
// 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() {
|
||||
@@ -364,19 +558,47 @@ func (g *Game) resolveBattle() {
|
||||
emit(BattleEvent{Type: "setaside", Seat: seat, Card: &c,
|
||||
Text: fmt.Sprintf("%s is set aside.", c.Name)})
|
||||
}
|
||||
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionSummonTop:
|
||||
target := seat
|
||||
if e.Target == "enemy" {
|
||||
target = (seat + 1) % n
|
||||
}
|
||||
for range effectCount(e, s, u) {
|
||||
summon(target, mintFor(e.Card), fmt.Sprintf("%s's faint effect", u.Card.Name))
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
summon(target, mintFor(e.Card), cause)
|
||||
}
|
||||
case ActionSummonBottom:
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if e.Target == "all" {
|
||||
for other := range sides {
|
||||
summonBottom(other, mintFor(e.Card), cause)
|
||||
}
|
||||
} else {
|
||||
summonBottom(seat, mintFor(e.Card), cause)
|
||||
}
|
||||
}
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionDrainTrumpet:
|
||||
es := enemyOf(seat)
|
||||
lost := min(e.count(), es.trumpets)
|
||||
if lost > 0 {
|
||||
es.trumpets -= lost
|
||||
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
|
||||
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
|
||||
}
|
||||
case ActionPreventNextHit:
|
||||
s.hitPrevent = append(s.hitPrevent, e.count())
|
||||
s.preventCards = append(s.preventCards, u.Card)
|
||||
setAside()
|
||||
case ActionRecycleApples:
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
@@ -385,6 +607,28 @@ func (g *Game) resolveBattle() {
|
||||
recycled++
|
||||
}
|
||||
}
|
||||
case ActionRecyclePerkApples:
|
||||
// Macaque: recycle up to Count apples, then the active perk on
|
||||
// top (so the perk reveals first and re-attaches to the next pet).
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f, cause)
|
||||
recycled++
|
||||
}
|
||||
}
|
||||
if perk := u.activePerk(); perk != nil {
|
||||
summon(seat, *perk, cause)
|
||||
}
|
||||
case ActionBeeRocks:
|
||||
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionFeedOnPlay:
|
||||
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionGuardRetriever:
|
||||
s.retrieverGuards = append(s.retrieverGuards, e.count())
|
||||
setAside()
|
||||
case ActionDelayedRocks:
|
||||
s.oneShotRocks = append(s.oneShotRocks,
|
||||
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||||
@@ -417,7 +661,7 @@ func (g *Game) resolveBattle() {
|
||||
if e.Trigger != TriggerEnemyFaint || e.Action != ActionHeal || !allowed(e, os.unit) {
|
||||
continue
|
||||
}
|
||||
healed := min(effectCount(e, os, os.unit), os.unit.Damage)
|
||||
healed := min(effectCount(e, os, os.unit, sides[seat]), os.unit.Damage)
|
||||
if healed > 0 {
|
||||
os.unit.Damage -= healed
|
||||
emit(BattleEvent{Type: "heal", Seat: other, DamageAfter: os.unit.Damage,
|
||||
@@ -436,18 +680,23 @@ func (g *Game) resolveBattle() {
|
||||
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionEatApple:
|
||||
for range effectCount(e, sides[seat], u) {
|
||||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||
u.Foods = append(u.Foods, g.newApple())
|
||||
u.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s eats an apple after being hurt (now +%d).", u.Card.Name, u.Bonus)})
|
||||
case ActionSummonTop:
|
||||
for range effectCount(e, sides[seat], u) {
|
||||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||||
}
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(seat, effectCount(e, sides[seat], u, enemyOf(seat)), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||||
case ActionShieldSelf:
|
||||
// Gorilla's own hurt-triggered block: innate, no card to drop.
|
||||
for range e.count() {
|
||||
@@ -457,6 +706,32 @@ func (g *Game) resolveBattle() {
|
||||
}
|
||||
}
|
||||
|
||||
// afterAttack fires After-Attack effects on a pet that survived a clash it
|
||||
// fought in (Bulldog eats an apple). Effects here are once per battle.
|
||||
afterAttack := func(seat int, u *BattleUnit) {
|
||||
if !u.Alive() || u.afterAttackUsed {
|
||||
return
|
||||
}
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerAfterAttack || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionEatApple:
|
||||
for range effectCount(e, sides[seat], u, enemyOf(seat)) {
|
||||
u.Foods = append(u.Foods, g.newApple())
|
||||
u.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s eats an apple after attacking (now +%d).", u.Card.Name, u.Bonus)})
|
||||
}
|
||||
u.afterAttackUsed = true
|
||||
}
|
||||
}
|
||||
|
||||
// throwRocks rolls `dice` rock dice against one seat's pet. Rocks are
|
||||
// not "attacks with" the pet, so no knockout applies. Reports a kill.
|
||||
throwRocks := func(from, target, dice int, source *Card) (killed bool) {
|
||||
@@ -470,7 +745,7 @@ func (g *Game) resolveBattle() {
|
||||
faces[i] = g.rollRockDie()
|
||||
roll += faces[i]
|
||||
}
|
||||
dealt, block := hitUnit(target, roll)
|
||||
dealt, block, prev := hitUnit(target, roll)
|
||||
died := !tu.Alive()
|
||||
// A set-aside pet (Snake/Blowfish/Badger/Croc) throws its rocks after a
|
||||
// different pet has been played, so credit `source` explicitly; only
|
||||
@@ -506,6 +781,9 @@ func (g *Game) resolveBattle() {
|
||||
if block != nil {
|
||||
emitShield(target, tu.Card.Name, block)
|
||||
}
|
||||
if prev != nil {
|
||||
emitPrevent(target, tu.Card.Name, prev)
|
||||
}
|
||||
if died {
|
||||
faint(target, tu)
|
||||
sides[target].unit = nil
|
||||
@@ -571,6 +849,7 @@ func (g *Game) resolveBattle() {
|
||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
|
||||
s.pending = nil
|
||||
s.unit = u
|
||||
s.petsPlayed++
|
||||
newlyPlayed[seat] = true
|
||||
// Set-aside payouts fire before the new pet's own play
|
||||
// effects.
|
||||
@@ -585,6 +864,31 @@ func (g *Game) resolveBattle() {
|
||||
plays = append(plays, queuedPlay{seat: seat,
|
||||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||||
}
|
||||
// Poison Dart Frog: rocks whenever a Bee is played.
|
||||
if isBee(c) {
|
||||
for _, r := range s.beePlayRocks {
|
||||
src := r.src
|
||||
plays = append(plays, queuedPlay{seat: seat,
|
||||
effect: Effect{Action: ActionThrowRock, Count: r.dice}, source: &src})
|
||||
}
|
||||
}
|
||||
// Giant Isopod: each set-aside spends one Trumpet (mandatory when
|
||||
// affordable) to feed the just-played pet its apples.
|
||||
for i := range s.feedOnPlay {
|
||||
if s.trumpets <= 0 {
|
||||
break
|
||||
}
|
||||
s.trumpets--
|
||||
src := s.feedOnPlay[i].src
|
||||
emit(BattleEvent{Type: "trumpet", Seat: seat, Count: -1,
|
||||
Text: fmt.Sprintf("%s spends 1 Trumpet.", src.Name)})
|
||||
for range s.feedOnPlay[i].apples {
|
||||
u.Foods = append(u.Foods, g.newApple())
|
||||
u.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus,
|
||||
Text: fmt.Sprintf("%s feeds %s %d apples (now +%d).", src.Name, c.Name, s.feedOnPlay[i].apples, u.Bonus)})
|
||||
}
|
||||
// Walk the pet's own play effects, then its active perk's, so a
|
||||
// play-time shield knows its source (an innate pet block vs a
|
||||
// Melon perk that should be shown and later dropped).
|
||||
@@ -593,8 +897,11 @@ func (g *Game) resolveBattle() {
|
||||
return
|
||||
}
|
||||
// Shields apply the instant the pet enters play, ahead of
|
||||
// any queued rocks (Melon).
|
||||
// any queued rocks (Melon; Wildebeest, which pays Trumpets).
|
||||
if e.Action == ActionShieldSelf {
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
return
|
||||
}
|
||||
for range e.count() {
|
||||
ch := shieldCharge{}
|
||||
if perk != nil {
|
||||
@@ -605,6 +912,13 @@ func (g *Game) resolveBattle() {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Potato's partial preventions likewise arm on entry.
|
||||
if e.Action == ActionPreventSelf {
|
||||
for range max(e.Cap, 1) {
|
||||
u.hitPrevent = append(u.hitPrevent, e.count())
|
||||
}
|
||||
return
|
||||
}
|
||||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||||
}
|
||||
for _, e := range u.Card.Effects {
|
||||
@@ -616,6 +930,24 @@ func (g *Game) resolveBattle() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Golden pack: a side that has run out of cards but still holds
|
||||
// Trumpets fields a one-time Golden Retriever, its Power equal to
|
||||
// those Trumpets. This happens before the "anyone out?" check so it
|
||||
// can still clash.
|
||||
if s.unit == nil && len(s.stack) == 0 && s.trumpets > 0 && !s.grSummoned {
|
||||
gr := g.newGoldenRetriever(s.trumpets)
|
||||
grUnit := &BattleUnit{Card: gr}
|
||||
// German Shepherd: each set-aside guards the Golden Retriever's
|
||||
// first hits (partial preventions).
|
||||
grUnit.hitPrevent = append(grUnit.hitPrevent, s.retrieverGuards...)
|
||||
s.unit = grUnit
|
||||
s.grSummoned = true
|
||||
s.petsPlayed++
|
||||
newlyPlayed[seat] = true
|
||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &gr, Count: s.trumpets, Bonus: 0,
|
||||
Text: fmt.Sprintf("%s is out of cards — a Golden Retriever charges in with %d Trumpet%s (Power %d).",
|
||||
pname(seat), s.trumpets, plural(s.trumpets), s.trumpets)})
|
||||
}
|
||||
}
|
||||
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||||
// Crocodile volleys when the enemy plays their last pet.
|
||||
@@ -655,20 +987,9 @@ func (g *Game) resolveBattle() {
|
||||
}
|
||||
}
|
||||
|
||||
// Battle over? A side that couldn't field a pet is out; no further
|
||||
// effects resolve.
|
||||
anyOut := false
|
||||
for _, s := range sides {
|
||||
if s.unit == nil {
|
||||
anyOut = true
|
||||
}
|
||||
}
|
||||
if anyOut {
|
||||
break
|
||||
}
|
||||
|
||||
// Resolve play effects. Any of these can faint a pet before the
|
||||
// clash.
|
||||
// Resolve play effects. Any of these can faint a pet before the clash.
|
||||
// This runs before the "battle over" check so a parting shot fires even
|
||||
// when its own side is already out (Crocodile's last-pet volley).
|
||||
anyDeath := false
|
||||
for _, q := range plays {
|
||||
// Effects sourced from a specific unit fizzle if it's gone.
|
||||
@@ -678,26 +999,97 @@ func (g *Game) resolveBattle() {
|
||||
if q.unit != nil && !allowed(q.effect, q.unit) {
|
||||
continue
|
||||
}
|
||||
costName := pname(q.seat)
|
||||
if q.unit != nil {
|
||||
costName = q.unit.Card.Name
|
||||
}
|
||||
if !spend(q.seat, q.effect, costName) {
|
||||
continue
|
||||
}
|
||||
switch q.effect.Action {
|
||||
case ActionThrowRock:
|
||||
dice := q.effect.count()
|
||||
if q.unit != nil {
|
||||
dice = effectCount(q.effect, sides[q.seat], q.unit)
|
||||
dice = effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||
}
|
||||
if q.everyone {
|
||||
switch {
|
||||
case q.everyone:
|
||||
for seat := range sides {
|
||||
if throwRocks(q.seat, seat, dice, q.source) {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
} else if t := nextTarget(q.seat); t >= 0 {
|
||||
if throwRocks(q.seat, t, dice, q.source) {
|
||||
case q.effect.Target == "self":
|
||||
// Manatee pelts its own pet.
|
||||
if throwRocks(q.seat, q.seat, dice, q.source) {
|
||||
anyDeath = true
|
||||
}
|
||||
default:
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
if throwRocks(q.seat, t, dice, q.source) {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.release != nil {
|
||||
emit(BattleEvent{Type: "release", Seat: q.seat, Card: q.release})
|
||||
}
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(q.seat, effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)), costName)
|
||||
case ActionDoubleTrumpets:
|
||||
gain := sides[q.seat].trumpets
|
||||
if q.effect.Cap > 0 && gain > q.effect.Cap {
|
||||
gain = q.effect.Cap
|
||||
}
|
||||
gainTrumpets(q.seat, gain, costName)
|
||||
case ActionStealPerk:
|
||||
t := nextTarget(q.seat)
|
||||
if t < 0 {
|
||||
continue
|
||||
}
|
||||
tu := sides[t].unit
|
||||
perk := tu.activePerk()
|
||||
if perk == nil {
|
||||
continue
|
||||
}
|
||||
stolen := *perk
|
||||
// Drop that perk food from the enemy pet (perks add no power, so
|
||||
// no bonus change) and put it on top of the thief's deck.
|
||||
for i := range tu.Foods {
|
||||
if tu.Foods[i].ID == stolen.ID {
|
||||
tu.Foods = append(tu.Foods[:i], tu.Foods[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
summon(q.seat, stolen, fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t,
|
||||
Text: fmt.Sprintf("%s snatches %s's %s.", q.unit.Card.Name, tu.Card.Name, stolen.Name)})
|
||||
case ActionStripApples:
|
||||
t := nextTarget(q.seat)
|
||||
if t < 0 {
|
||||
continue
|
||||
}
|
||||
tu := sides[t].unit
|
||||
apples := appleCount(tu.Foods)
|
||||
if apples == 0 {
|
||||
continue
|
||||
}
|
||||
kept := tu.Foods[:0]
|
||||
for _, f := range tu.Foods {
|
||||
if f.Food != FoodApple {
|
||||
kept = append(kept, f)
|
||||
}
|
||||
}
|
||||
tu.Foods = kept
|
||||
tu.Bonus -= apples
|
||||
died := !tu.Alive()
|
||||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
|
||||
Text: fmt.Sprintf("%s discards %s's apples.", q.unit.Card.Name, tu.Card.Name)})
|
||||
if died {
|
||||
faint(t, tu)
|
||||
sides[t].unit = nil
|
||||
anyDeath = true
|
||||
}
|
||||
case ActionStripFoods:
|
||||
t := nextTarget(q.seat)
|
||||
if t < 0 {
|
||||
@@ -758,11 +1150,11 @@ func (g *Game) resolveBattle() {
|
||||
Text: fmt.Sprintf("%s burns %s off %s's deck.", q.unit.Card.Name, top.Name, pname(t))})
|
||||
}
|
||||
case ActionSummonTop:
|
||||
for range effectCount(q.effect, sides[q.seat], q.unit) {
|
||||
for range effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat)) {
|
||||
summon(q.seat, mintFor(q.effect.Card), fmt.Sprintf("%s's ability", q.unit.Card.Name))
|
||||
}
|
||||
case ActionEatApple:
|
||||
count := effectCount(q.effect, sides[q.seat], q.unit)
|
||||
count := effectCount(q.effect, sides[q.seat], q.unit, enemyOf(q.seat))
|
||||
if count > 0 {
|
||||
for range count {
|
||||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||||
@@ -771,7 +1163,65 @@ func (g *Game) resolveBattle() {
|
||||
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: q.unit.Bonus,
|
||||
Text: fmt.Sprintf("%s eats an apple (now +%d).", q.unit.Card.Name, q.unit.Bonus)})
|
||||
}
|
||||
case ActionShuffleApples:
|
||||
// Komodo: only if it's the side's first pet, shuffle apples into
|
||||
// random deck positions (deterministic via the battle draw tape).
|
||||
if q.effect.Condition == ConditionFirstPet && sides[q.seat].petsPlayed != 1 {
|
||||
continue
|
||||
}
|
||||
s := sides[q.seat]
|
||||
for range q.effect.count() {
|
||||
pos := g.battleDraw(len(s.stack) + 1)
|
||||
apple := g.newApple()
|
||||
s.stack = slices.Insert(s.stack, pos, apple)
|
||||
emit(BattleEvent{Type: "summon", Seat: q.seat, Card: &apple,
|
||||
Text: fmt.Sprintf("%s shuffles an apple into %s's deck.", q.unit.Card.Name, pname(q.seat))})
|
||||
}
|
||||
case ActionSpendRocks:
|
||||
// Nurse Shark: the owner chooses how many Trumpets (0..available,
|
||||
// capped at Count) to spend; each throws two rocks. This is the one
|
||||
// mid-battle decision — it may suspend the whole simulation.
|
||||
s := sides[q.seat]
|
||||
maxSpend := min(q.effect.count(), s.trumpets)
|
||||
choice, pending := g.decideBattle(PendingBattleDecision{
|
||||
Seat: q.seat, Kind: "nurseShark", PetName: q.unit.Card.Name,
|
||||
Min: 0, Max: maxSpend, Trumpets: s.trumpets,
|
||||
})
|
||||
if pending != nil {
|
||||
suspended = pending
|
||||
break
|
||||
}
|
||||
if choice > 0 {
|
||||
s.trumpets -= choice
|
||||
emit(BattleEvent{Type: "trumpet", Seat: q.seat, Count: -choice,
|
||||
Text: fmt.Sprintf("%s spends %d Trumpet%s.", q.unit.Card.Name, choice, plural(choice))})
|
||||
if t := nextTarget(q.seat); t >= 0 {
|
||||
if throwRocks(q.seat, t, 2*choice, nil) {
|
||||
anyDeath = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if suspended != nil {
|
||||
break // stop mid-plays; the battle re-runs once the choice is in
|
||||
}
|
||||
}
|
||||
// A pending decision unwinds the whole simulation; the events emitted so
|
||||
// far are a valid prefix the re-run reproduces exactly.
|
||||
if suspended != nil {
|
||||
return res, suspended
|
||||
}
|
||||
// Battle over? A side that can no longer field a pet is out (checked
|
||||
// after play effects so parting shots land, using canField so a pet that
|
||||
// will simply refill next reveal doesn't count as out).
|
||||
anyOut := false
|
||||
for _, s := range sides {
|
||||
if !s.canField() {
|
||||
anyOut = true
|
||||
}
|
||||
}
|
||||
if anyOut {
|
||||
break
|
||||
}
|
||||
if anyDeath {
|
||||
continue // refill before any clash
|
||||
@@ -781,8 +1231,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, blockA := hitUnit(0, powB)
|
||||
dealtB, blockB := hitUnit(1, powA)
|
||||
dealtA, blockA, prevA := hitUnit(0, powB)
|
||||
dealtB, blockB, prevB := hitUnit(1, powA)
|
||||
// Scorpion: a clash attack that hurts, KOs.
|
||||
if dealtA > 0 && ub.hasKnockout() {
|
||||
ua.Damage = max(ua.Damage, ua.Power())
|
||||
@@ -828,7 +1278,14 @@ func (g *Game) resolveBattle() {
|
||||
if blockB != nil {
|
||||
emitShield(1, ub.Card.Name, blockB)
|
||||
}
|
||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && blockA == nil && blockB == nil {
|
||||
if prevA != nil {
|
||||
emitPrevent(0, ua.Card.Name, prevA)
|
||||
}
|
||||
if prevB != nil {
|
||||
emitPrevent(1, ub.Card.Name, prevB)
|
||||
}
|
||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 &&
|
||||
blockA == nil && blockB == nil && prevA == nil && prevB == nil {
|
||||
break // stalemate: nothing can ever change
|
||||
}
|
||||
dealt := []int{dealtA, dealtB}
|
||||
@@ -836,8 +1293,11 @@ func (g *Game) resolveBattle() {
|
||||
if !u.Alive() {
|
||||
faint(seat, u)
|
||||
sides[seat].unit = nil
|
||||
} else if dealt[seat] > 0 {
|
||||
hurt(seat, u)
|
||||
} else {
|
||||
if dealt[seat] > 0 {
|
||||
hurt(seat, u)
|
||||
}
|
||||
afterAttack(seat, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -860,27 +1320,6 @@ func (g *Game) resolveBattle() {
|
||||
if g.Round == MaxRounds {
|
||||
res.Trophies = 2
|
||||
}
|
||||
g.Players[winner].Trophies += res.Trophies
|
||||
// Priority token: the winner hands it to the other player; a loser
|
||||
// who held it keeps it; a draw leaves it put. (Two-player rule; the
|
||||
// "other player" is unambiguous only at n == 2.)
|
||||
if winner == g.PrioritySeat {
|
||||
g.PrioritySeat = (winner + 1) % n
|
||||
}
|
||||
}
|
||||
|
||||
g.Battle = res
|
||||
g.Phase = PhaseBattle
|
||||
// Tagged "result" so the client can hold it back until the replay finishes
|
||||
// (the outcome is known now, but showing it early would spoil the battle).
|
||||
if winner < 0 {
|
||||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||||
} else {
|
||||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)})
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
p.Ready = false
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user