740 lines
23 KiB
Go
740 lines
23 KiB
Go
package game
|
||
|
||
import "fmt"
|
||
|
||
// BattleUnit is a pet in play with its attached foods applied. Power
|
||
// (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||
type BattleUnit struct {
|
||
Card Card `json:"card"`
|
||
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)
|
||
}
|
||
|
||
func (u *BattleUnit) Power() int { return u.Card.Power + u.Bonus }
|
||
func (u *BattleUnit) Alive() bool { return u.Damage < u.Power() }
|
||
|
||
// activePerk returns the perk this unit benefits from: only the
|
||
// last-applied perk counts when several are attached.
|
||
func (u *BattleUnit) activePerk() *Card {
|
||
for i := len(u.Foods) - 1; i >= 0; i-- {
|
||
if u.Foods[i].Perk {
|
||
return &u.Foods[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// effects returns the unit's live effects: its own plus its active perk's.
|
||
func (u *BattleUnit) effects() []Effect {
|
||
effs := u.Card.Effects
|
||
if perk := u.activePerk(); perk != nil {
|
||
effs = append(append([]Effect(nil), effs...), perk.Effects...)
|
||
}
|
||
return effs
|
||
}
|
||
|
||
// prevention totals the unit's passive per-attack damage reduction (Garlic).
|
||
func (u *BattleUnit) prevention() int {
|
||
total := 0
|
||
for _, e := range u.effects() {
|
||
if e.Trigger == TriggerPassive && e.Action == ActionPreventDamage {
|
||
total += e.count()
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
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.
|
||
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/Target must NOT be omitempty: 0 is a valid seat (the first
|
||
// player) and dropping it makes the client read sides[undefined].
|
||
Seat int `json:"seat"`
|
||
Target int `json:"target"`
|
||
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: individual die faces rolled (each 0, 1, or 2), for the dice
|
||
// animation; Roll is their sum.
|
||
Dice []int `json:"dice,omitempty"`
|
||
// rock: dice total rolled; rock/strip/steal: target pet's fate.
|
||
Roll int `json:"roll"`
|
||
DamageAfter int `json:"damageAfter"`
|
||
TargetDied bool `json:"targetDied,omitempty"`
|
||
// eat: the pet's power bonus after eating.
|
||
Bonus int `json:"bonus"`
|
||
// Text is a human-readable description of this step for the event log,
|
||
// e.g. "Ant's faint effect summons a Bee." Empty for steps not worth a
|
||
// line (they still animate).
|
||
Text string `json:"text,omitempty"`
|
||
}
|
||
|
||
// BattleResult is the full, public record of one round's battle.
|
||
type BattleResult struct {
|
||
Round int `json:"round"`
|
||
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||
// Lineups is each seat's arranged deck at battle start (top of deck
|
||
// first). Public so players can review the whole matchup — including the
|
||
// opponent's cards — during and after the fight.
|
||
Lineups [][]Card `json:"lineups,omitempty"`
|
||
Events []BattleEvent `json:"events"`
|
||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||
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 (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 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,
|
||
// records the event log, awards trophies, and moves to PhaseBattle.
|
||
//
|
||
// 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, 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), Lineups: make([][]Card, n)}
|
||
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...)}
|
||
sides[p.Seat] = s
|
||
res.StackSizes[p.Seat] = len(p.Deck)
|
||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||
}
|
||
// 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
|
||
// holder acts first — its rocks can faint the enemy pet before that pet's
|
||
// own queued rocks resolve.
|
||
seatOrder := make([]int, 0, n)
|
||
seatOrder = append(seatOrder, g.PrioritySeat)
|
||
for seat := range sides {
|
||
if seat != g.PrioritySeat {
|
||
seatOrder = append(seatOrder, seat)
|
||
}
|
||
}
|
||
// 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,
|
||
Text: fmt.Sprintf("%s starts the battle with an apple in play.", pname(p.Seat))})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
summon := func(seat int, c Card, cause string) {
|
||
s := sides[seat]
|
||
s.stack = append([]Card{c}, s.stack...)
|
||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s summons %s %s.", cause, article(c.Name), c.Name)})
|
||
}
|
||
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, 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) {
|
||
s.beesFainted++
|
||
}
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||
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))
|
||
}
|
||
case ActionRecycleApples:
|
||
recycled := 0
|
||
for _, f := range u.Foods {
|
||
if f.Food == FoodApple && recycled < e.count() {
|
||
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
|
||
recycled++
|
||
}
|
||
}
|
||
case ActionDelayedRocks:
|
||
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,
|
||
Text: fmt.Sprintf("%s heals %d after an enemy faints.", os.unit.Card.Name, healed)})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// hurt fires Hurt effects on a pet that was damaged and survived.
|
||
hurt := func(seat int, u *BattleUnit) {
|
||
if !u.Alive() {
|
||
return
|
||
}
|
||
for _, e := range u.effects() {
|
||
if e.Trigger != TriggerHurt || !allowed(e, u) {
|
||
continue
|
||
}
|
||
switch e.Action {
|
||
case ActionEatApple:
|
||
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,
|
||
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) {
|
||
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
|
||
}
|
||
case ActionShieldSelf:
|
||
u.Shield += e.count()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
return false
|
||
}
|
||
roll := 0
|
||
faces := make([]int, dice)
|
||
for i := range dice {
|
||
faces[i] = g.rollRockDie()
|
||
roll += faces[i]
|
||
}
|
||
dealt, blocked := hitUnit(target, roll)
|
||
died := !tu.Alive()
|
||
thrower := pname(from)
|
||
if su := sides[from].unit; su != nil {
|
||
thrower = su.Card.Name
|
||
}
|
||
var rockTxt string
|
||
switch {
|
||
case roll == 0:
|
||
rockTxt = fmt.Sprintf("%s's rocks miss %s.", thrower, tu.Card.Name)
|
||
case died:
|
||
rockTxt = fmt.Sprintf("%s pelts %s for %d — it faints.", thrower, tu.Card.Name, roll)
|
||
default:
|
||
rockTxt = fmt.Sprintf("%s pelts %s for %d.", thrower, tu.Card.Name, roll)
|
||
}
|
||
emit(BattleEvent{
|
||
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 died {
|
||
faint(target, tu)
|
||
sides[target].unit = nil
|
||
return true
|
||
}
|
||
if dealt > 0 {
|
||
hurt(target, tu)
|
||
}
|
||
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/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 := range seatOrder {
|
||
s := sides[seat]
|
||
for s.unit == nil && len(s.stack) > 0 {
|
||
c := s.stack[0]
|
||
s.stack = s.stack[1:]
|
||
if c.IsFood() {
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c,
|
||
Text: fmt.Sprintf("%s's %s is set aside for the next pet.", pname(seat), c.Name)})
|
||
s.pending = append(s.pending, c)
|
||
continue
|
||
}
|
||
u := &BattleUnit{Card: c, Foods: s.pending}
|
||
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.
|
||
revealTxt := fmt.Sprintf("%s's %s enters the fray.", pname(seat), c.Name)
|
||
if u.Bonus > 0 {
|
||
revealTxt = fmt.Sprintf("%s's %s enters the fray (+%d).", pname(seat), c.Name, u.Bonus)
|
||
}
|
||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus, Text: revealTxt})
|
||
s.pending = nil
|
||
s.unit = u
|
||
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}})
|
||
}
|
||
for _, e := range u.effects() {
|
||
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 seatOrder {
|
||
if !newlyPlayed[seat] {
|
||
continue
|
||
}
|
||
for _, other := range seatOrder {
|
||
os := sides[other]
|
||
if other == seat {
|
||
continue
|
||
}
|
||
if os.unit != nil {
|
||
for _, e := range os.unit.effects() {
|
||
if e.Trigger == TriggerEnemyPlay && e.Action == ActionThrowRock && allowed(e, os.unit) {
|
||
plays = append(plays, queuedPlay{seat: other, unit: os.unit, effect: e})
|
||
}
|
||
}
|
||
}
|
||
if !sides[seat].hasPetInStack() {
|
||
for _, dice := range os.lastPetRocks {
|
||
plays = append(plays, queuedPlay{seat: other,
|
||
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
||
}
|
||
os.lastPetRocks = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// Battle over? A side that couldn't field a pet is out; no further
|
||
// 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.
|
||
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:
|
||
dice := q.effect.count()
|
||
if q.unit != nil {
|
||
dice = effectCount(q.effect, sides[q.seat], q.unit)
|
||
}
|
||
if q.everyone {
|
||
for seat := range sides {
|
||
if throwRocks(q.seat, seat, dice) {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
} else if t := nextTarget(q.seat); t >= 0 {
|
||
if throwRocks(q.seat, t, dice) {
|
||
anyDeath = true
|
||
}
|
||
}
|
||
case ActionStripFoods:
|
||
t := nextTarget(q.seat)
|
||
if t < 0 {
|
||
continue
|
||
}
|
||
tu := sides[t].unit
|
||
tu.Bonus -= appleCount(tu.Foods)
|
||
tu.Foods = nil
|
||
died := !tu.Alive()
|
||
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died,
|
||
Text: fmt.Sprintf("%s strips %s's apples away.", q.unit.Card.Name, tu.Card.Name)})
|
||
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,
|
||
Text: fmt.Sprintf("%s steals %d apple%s from %s.", q.unit.Card.Name, moved, plural(moved), tu.Card.Name)})
|
||
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,
|
||
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) {
|
||
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)
|
||
if count > 0 {
|
||
for range count {
|
||
q.unit.Foods = append(q.unit.Foods, g.newApple())
|
||
q.unit.Bonus++
|
||
}
|
||
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)})
|
||
}
|
||
}
|
||
}
|
||
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
|
||
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())
|
||
}
|
||
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
|
||
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
|
||
switch da, db := !ua.Alive(), !ub.Alive(); {
|
||
case da && db:
|
||
clashTxt += " Both faint."
|
||
case da:
|
||
clashTxt += fmt.Sprintf(" %s faints.", ua.Card.Name)
|
||
case db:
|
||
clashTxt += fmt.Sprintf(" %s faints.", ub.Card.Name)
|
||
}
|
||
emit(BattleEvent{
|
||
Type: "clash",
|
||
Damage: []int{ua.Damage, ub.Damage},
|
||
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 blockedB {
|
||
emit(BattleEvent{Type: "shield", Seat: 1,
|
||
Text: fmt.Sprintf("%s blocks the hit with a shield.", ub.Card.Name)})
|
||
}
|
||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 && !blockedA && !blockedB {
|
||
break // stalemate: nothing can ever change
|
||
}
|
||
dealt := []int{dealtA, dealtB}
|
||
for seat, u := range []*BattleUnit{ua, ub} {
|
||
if !u.Alive() {
|
||
faint(seat, u)
|
||
sides[seat].unit = nil
|
||
} else if dealt[seat] > 0 {
|
||
hurt(seat, u)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A single side still holding a pet in play wins; anything else
|
||
// (everyone out, or a stalemate with pets on both sides) is a draw.
|
||
winner := -1
|
||
for seat, s := range sides {
|
||
if s.unit != nil {
|
||
if winner >= 0 {
|
||
winner = -1 // stalemate / >2-player safety
|
||
break
|
||
}
|
||
winner = seat
|
||
}
|
||
}
|
||
res.WinnerSeat = winner
|
||
if winner >= 0 {
|
||
res.Trophies = 1
|
||
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: "result",
|
||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||
} else {
|
||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: "result",
|
||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)})
|
||
}
|
||
for _, p := range g.Players {
|
||
p.Ready = false
|
||
}
|
||
}
|