Files
super-auto-pets-board-game/internal/game/battle.go
T

773 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
//
// clashStreak counts how many times the current pair has clashed without
// either fainting. Some effects (e.g. eat-an-apple-when-hurt) can make two
// pets un-killable, looping forever; if they trade blows deadlockLimit
// times in a row, both drop so the battle can proceed. It resets whenever
// the matchup changes (a fresh pet enters).
const deadlockLimit = 9
clashStreak := 0
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
}
}
}
// A fresh pet on either side is a new matchup, so the deadlock streak
// starts over.
for _, np := range newlyPlayed {
if np {
clashStreak = 0
break
}
}
// 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())
}
// Deadlock guard: if the pair keeps surviving, count the exchanges and
// force a mutual knockout once they hit the limit.
deadlocked := false
if ua.Alive() && ub.Alive() {
clashStreak++
if clashStreak >= deadlockLimit {
deadlocked = true
ua.Damage = max(ua.Damage, ua.Power())
ub.Damage = max(ub.Damage, ub.Power())
}
} else {
clashStreak = 0
}
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 deadlocked:
clashTxt = fmt.Sprintf("%s's %s and %s's %s are deadlocked after %d clashes — both faint.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name, deadlockLimit)
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
}
}