Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+299 -231
View File
@@ -2,6 +2,7 @@ package game
import (
"fmt"
"slices"
)
// BattleUnit is a pet in play with its attached foods applied. Power
@@ -23,8 +24,8 @@ type BattleUnit struct {
// Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the
// damage it deals in a clash (min 0); Exposed raises the damage it takes on
// each hit. bakuGuard, when set, discards the first Ailment it would gain.
Spooked int `json:"spooked,omitempty"`
Exposed int `json:"exposed,omitempty"`
Spooked int `json:"spooked,omitempty"`
Exposed int `json:"exposed,omitempty"`
bakuGuard bool
}
@@ -179,29 +180,57 @@ type BattleEvent struct {
Text string `json:"text,omitempty"`
}
// BattleResult is the full, public record of one round's battle.
// BattleResult is the full, public record of one battle.
//
// A round runs one battle per pairing (see schedule.go), so a six-player round
// produces three of these. Everything inside a result is indexed by *side* —
// 0 or 1 within this battle — not by the player's seat at the table: Seats maps
// the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat
// is the exception, and is a real seat, because it's the one field that means
// something outside the 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
Round int `json:"round"`
// Seats are the two players fighting, in first-player order: Seats[0] holds
// priority and acts first when two effects would land simultaneously.
Seats []int `json:"seats"`
StackSizes []int `json:"stackSizes"` // starting deck size per side
// Lineups is each side'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
WinnerSeat int `json:"winnerSeat"` // a seat at the table; -1 = draw
Trophies int `json:"trophies"` // awarded to the winner
// Survivors is each seat's remaining force at battle end: pets still in
// Survivors is each side's remaining force at battle end: pets still in
// play plus any never reached in the stack. The loser is 0. It measures how
// decisive the result was — the margin the AI uses to prefer a lineup that
// fights harder, even in a battle it can't win.
Survivors []int `json:"survivors,omitempty"`
// ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the
// ManaAfter (Unicorn pack) is each side's persistent Mana pool once the
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
// is apples each seat banked for next round's hand (Skeleton Dog).
// is apples each side banked for next round's hand (Skeleton Dog).
ManaAfter []int `json:"manaAfter,omitempty"`
NextRoundApples []int `json:"nextRoundApples,omitempty"`
}
// Side returns the battle-side index (0 or 1) for a seat at the table, or -1
// if that player wasn't in this battle. Use it to read any of the per-side
// slices above from a seat.
func (r *BattleResult) Side(seat int) int {
return slices.Index(r.Seats, seat)
}
// Has reports whether a seat fought in this battle.
func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 }
// SeatOf returns the seat holding a side of this battle, or -1.
func (r *BattleResult) SeatOf(side int) int {
if side < 0 || side >= len(r.Seats) {
return -1
}
return r.Seats[side]
}
// setAsideRocks is a fainted pet's pending rock payout.
type setAsideRocks struct {
dice int
@@ -240,11 +269,11 @@ type battleSide struct {
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
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")
@@ -324,10 +353,10 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
return n
}
// resolveBattle simulates the battle from the players' arranged decks,
// records the event log, awards trophies, and moves to PhaseBattle.
// resolveBattles fights every pairing of the current round, records the event
// logs, and awards trophies.
//
// The battle is a stack machine: each side reveals cards off the top of
// Each 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,
@@ -339,83 +368,119 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
// attack manages to hurt. A clash that changes nothing ends the battle as a
// stalemate.
//
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and
// publishes the completed result.
func (g *Game) resolveBattle() {
res := g.runBattle()
g.Battle = res
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. Kept separate from runBattle, which mutates no persistent
// player state.
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)})
// resolveBattles is the orchestrator: it runs each (deterministic) simulation
// and publishes the completed results.
func (g *Game) resolveBattles() {
g.Battles = nil
for _, m := range g.Pairings() {
first, second := g.firstPlayer(m)
res := g.runBattle(first, second)
g.Battles = append(g.Battles, res)
g.finalizeBattle(res)
}
// Per-round bookkeeping that isn't tied to one battle: the temporary
// resources every player banked for the fight are spent now, win or lose.
for _, p := range g.Players {
p.PendingApplesInPlay = 0
p.PendingTrumpets = 0
}
}
// firstPlayer decides which half of a pairing acts first — the side that wins
// simultaneity races during the battle. Two players settle it with the
// priority token they pass between them; a bigger table flips for it, as the
// rulebook's "determine the First Player for each battle by flipping a gold
// token" asks.
func (g *Game) firstPlayer(m Matchup) (first, second int) {
if len(g.Players) == 2 {
if m[1] == g.PrioritySeat {
return m[1], m[0]
}
return m[0], m[1]
}
if randInt(2) == 1 {
return m[1], m[0]
}
return m[0], m[1]
}
// finalizeBattle applies the persistent effects of one completed battle:
// trophies, the round-win record, the priority token hand-off, and the result
// log line. Kept separate from runBattle, which mutates no persistent player
// state.
func (g *Game) finalizeBattle(res *BattleResult) {
winner := res.WinnerSeat
if winner >= 0 {
g.Players[winner].Trophies += res.Trophies
g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round)
// Priority token (two-player rule): the winner hands it to the other
// player; a loser who held it keeps it; a draw leaves it put. At bigger
// tables the token instead walks the table each round (startShopRound).
if len(g.Players) == 2 && winner == g.PrioritySeat {
g.PrioritySeat = (winner + 1) % len(g.Players)
}
}
// The result line names the table it came from, since several resolve at once.
loser := res.SeatOf(0)
if loser == winner {
loser = res.SeatOf(1)
}
if winner < 0 {
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))})
} else {
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)})
}
for _, seat := range res.Seats {
p := g.Players[seat]
side := res.Side(seat)
// Unicorn pack: persist the Mana pool as it stood at battle's end, and
// bank any apples destined for next round's hand (Skeleton Dog).
if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
p.Mana = res.ManaAfter[p.Seat]
if side < len(res.ManaAfter) {
p.Mana = res.ManaAfter[side]
}
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
p.NextRoundApples += res.NextRoundApples[p.Seat]
if side < len(res.NextRoundApples) {
p.NextRoundApples += res.NextRoundApples[side]
}
}
}
// runBattle plays the simulation to completion, returning the result. It
// mutates no persistent player state — that is finalizeBattle's job.
func (g *Game) runBattle() *BattleResult {
n := len(g.Players)
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
// runBattle plays one pairing's simulation to completion, returning the
// result. It mutates no persistent player state — that is finalizeBattle's job.
//
// first and second are the seats fighting, first having priority. Everything
// below works in *side* indices — 0 is first, 1 is second — so the resolver
// only ever deals with two combatants no matter how big the table is; res.Seats
// maps back out. Read `seat` in this function as "side" throughout.
func (g *Game) runBattle(first, second int) *BattleResult {
const n = 2 // sides in a battle, not players at the table
seats := []int{first, second}
res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats,
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 }
// pname is the owning player's display name for a side, for log text.
pname := func(side int) string { return g.Players[seats[side]].Name }
for _, p := range g.Players {
for side, seat := range seats {
p := g.Players[seat]
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
// Unicorn pack: the persistent Mana pool comes into battle (read-only
// here; written back by finalizeBattle so re-runs stay deterministic).
s.mana = p.Mana
sides[p.Seat] = s
res.StackSizes[p.Seat] = len(p.Deck)
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
sides[side] = s
res.StackSizes[side] = len(p.Deck)
res.Lineups[side] = append([]Card(nil), p.Deck...)
}
// enemyOf returns the opposing side (two-player; generalizes later).
// enemyOf returns the opposing side.
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
// 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)
}
}
// seatOrder resolves the first player before the second. Reveals, queued
// play effects, and cross-side triggers all follow it, so when two pets
// would act simultaneously (e.g. both throwing rocks) the first player acts
// first — its rocks can faint the enemy pet before that pet's own queued
// rocks resolve. Side 0 is the first player by construction.
seatOrder := []int{0, 1}
// startApple seeds one in-play apple onto a seat's first pet.
startApple := func(seat int) {
apple := g.newApple()
@@ -425,27 +490,28 @@ func (g *Game) runBattle() *BattleResult {
}
// Battle-prep effects that start apples in play (Monkey): they attach
// to the owner's first pet.
for _, p := range g.Players {
for side, seat := range seats {
p := g.Players[seat]
for _, c := range p.Deck {
for _, e := range c.Effects {
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
for range e.count() {
startApple(p.Seat)
startApple(side)
}
}
}
}
// 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).
// round (read-only here; resolveBattles clears it once the round's
// battles end, so re-runs bank the same amount).
for range p.PendingApplesInPlay {
startApple(p.Seat)
startApple(side)
}
// 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))})
sides[side].trumpets += p.PendingTrumpets
emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets,
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), p.PendingTrumpets, plural(p.PendingTrumpets))})
}
}
@@ -731,160 +797,160 @@ func (g *Game) runBattle() *BattleResult {
}
}
{
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
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
for _, e := range u.effects() {
if e.Trigger != TriggerFaint || !allowed(e, u) {
continue
}
for range effectCount(e, s, u, enemyOf(seat)) {
summon(target, mintFor(e.Card), cause)
if !spend(seat, e, u.Card.Name) {
continue
}
case ActionSummonBottom:
for range effectCount(e, s, u, enemyOf(seat)) {
if e.Target == "all" {
for other := range sides {
summonBottom(other, mintFor(e.Card), cause)
switch e.Action {
case ActionSummonTop:
target := seat
if e.Target == "enemy" {
target = (seat + 1) % n
}
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)
}
} 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 {
if f.Food == FoodApple && recycled < e.count() {
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
recycled++
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 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++
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 {
if f.Food == FoodApple && recycled < e.count() {
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
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})
setAside()
case ActionRecurringRocks:
s.recurringRocks = append(s.recurringRocks,
setAsideRocks{dice: e.count(), src: u.Card})
setAside()
case ActionEnemyLastPetRocks:
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()
case ActionGainMana:
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
case ActionAddAilment:
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
case ActionNextRoundApple:
// Banked for next round's hand; surfaced then in the shop log
// rather than as a battle-board change now.
s.nextRoundApples += e.count()
case ActionNegateEnemyFaint:
s.negators = append(s.negators, u.Card)
setAside()
case ActionReviveSelf:
// Slime: put a plain copy back on top of the deck — no faint
// ability, so it can't loop. "Once per round" falls out of that.
revived := u.Card
revived.ID = g.newCardID()
revived.Effects = nil
revived.EffectText = ""
summon(seat, revived, cause)
case ActionAilmentToApples:
s.unicornGuards = append(s.unicornGuards, u.Card)
setAside()
case ActionSmallPetAura:
s.smallPetBonus += e.count()
setAside()
case ActionAilmentBoost:
s.ailmentBoost += e.count()
setAside()
case ActionManaFeedOnPlay:
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
setAside()
case ActionReviveNextFaint:
s.fairyGuards = append(s.fairyGuards, u.Card)
setAside()
case ActionSummonFromDiscard:
// Chimera: add Count random cards from the FromTier discard pile as
// temporary copies on top of the deck.
pile := g.Discards[e.FromTier]
for range effectCount(e, s, u, enemyOf(seat)) {
if len(pile) == 0 {
break
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++
}
}
pick := pile[g.battleDraw(len(pile))]
copyC := pick
copyC.ID = g.newCardID()
copyC.Temporary = true
summon(seat, copyC, cause)
}
case ActionSummonFromTierDeck:
// Pixiu: a temporary copy of the top of the FromTier shop deck.
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
deck := g.ShopDecks[e.FromTier-1]
if len(deck) > 0 {
copyC := deck[0]
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})
setAside()
case ActionRecurringRocks:
s.recurringRocks = append(s.recurringRocks,
setAsideRocks{dice: e.count(), src: u.Card})
setAside()
case ActionEnemyLastPetRocks:
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()
case ActionGainMana:
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
case ActionAddAilment:
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
case ActionNextRoundApple:
// Banked for next round's hand; surfaced then in the shop log
// rather than as a battle-board change now.
s.nextRoundApples += e.count()
case ActionNegateEnemyFaint:
s.negators = append(s.negators, u.Card)
setAside()
case ActionReviveSelf:
// Slime: put a plain copy back on top of the deck — no faint
// ability, so it can't loop. "Once per round" falls out of that.
revived := u.Card
revived.ID = g.newCardID()
revived.Effects = nil
revived.EffectText = ""
summon(seat, revived, cause)
case ActionAilmentToApples:
s.unicornGuards = append(s.unicornGuards, u.Card)
setAside()
case ActionSmallPetAura:
s.smallPetBonus += e.count()
setAside()
case ActionAilmentBoost:
s.ailmentBoost += e.count()
setAside()
case ActionManaFeedOnPlay:
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
setAside()
case ActionReviveNextFaint:
s.fairyGuards = append(s.fairyGuards, u.Card)
setAside()
case ActionSummonFromDiscard:
// Chimera: add Count random cards from the FromTier discard pile as
// temporary copies on top of the deck.
pile := g.Discards[e.FromTier]
for range effectCount(e, s, u, enemyOf(seat)) {
if len(pile) == 0 {
break
}
pick := pile[g.battleDraw(len(pile))]
copyC := pick
copyC.ID = g.newCardID()
copyC.Temporary = true
summon(seat, copyC, cause)
}
case ActionSummonFromTierDeck:
// Pixiu: a temporary copy of the top of the FromTier shop deck.
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
deck := g.ShopDecks[e.FromTier-1]
if len(deck) > 0 {
copyC := deck[0]
copyC.ID = g.newCardID()
copyC.Temporary = true
summon(seat, copyC, cause)
}
}
}
}
}
}
enemyReactions:
// Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck
// bottom (a fresh copy, so it re-enters later and can faint again).
@@ -1686,8 +1752,7 @@ func (g *Game) runBattle() *BattleResult {
continue // refill before any clash
}
// Clash. Two-player for now; >2-player battle pairings come later
// (the surrounding state is already per-seat).
// Clash: the two sides' pets trade blows. A is the first player's side.
ua, ub := sides[0].unit, sides[1].unit
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
// to the defender inside hitUnit); a Manticore boosts enemy ailments.
@@ -1768,23 +1833,26 @@ func (g *Game) runBattle() *BattleResult {
}
}
// A single side that can still field a pet wins; anything else (everyone
// out, or a stalemate with pets on both sides) is a draw. We test canField,
// A single side that can still field a pet wins; anything else (both out,
// or a stalemate with pets on both sides) is a draw. We test canField,
// not unit, because the loop can break the instant one side runs out while
// the other's current pet has just fainted — that side still has pets left
// in its stack (it simply wasn't refilled) and is the rightful winner.
winner := -1
for seat, s := range sides {
for side, s := range sides {
if s.canField() {
if winner >= 0 {
winner = -1 // stalemate / >2-player safety
winner = -1 // both still standing: a stalemate draw
break
}
winner = seat
winner = side
}
}
res.WinnerSeat = winner
// The winner leaves this function as a seat at the table, the one piece of
// the result that means anything outside the battle.
if winner >= 0 {
res.WinnerSeat = seats[winner]
// The last round is worth double.
res.Trophies = 1
if g.Round == MaxRounds {
res.Trophies = 2