Add support for up to 6 players.
This commit is contained in:
+299
-231
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user