Add pets for tiers 1-3.
This commit is contained in:
+339
-80
@@ -1,117 +1,376 @@
|
||||
package game
|
||||
|
||||
// BattleUnit is a pet on the battle line with its attached foods applied.
|
||||
// Power (attack) is unaffected by damage; a unit dies when Damage >= Power.
|
||||
// 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"`
|
||||
Bonus int `json:"bonus"` // total power added by foods
|
||||
Foods []Card `json:"foods,omitempty"`
|
||||
Bonus int `json:"bonus"` // total power added by foods (and eating)
|
||||
Damage int `json:"damage"` // damage markers accumulated this battle
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// takeHit applies one attack of the given strength and returns the damage
|
||||
// actually dealt after prevention.
|
||||
func (u *BattleUnit) takeHit(amount int) int {
|
||||
if amount <= 0 {
|
||||
return 0
|
||||
}
|
||||
dealt := max(0, amount-u.prevention())
|
||||
u.Damage += dealt
|
||||
return dealt
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Type string `json:"type"` // "clash"
|
||||
// Indexes into the initial lineups (per seat) of the two front pets.
|
||||
Units []int `json:"units"` // one entry per seat
|
||||
// Damage each front pet has accumulated after the clash, per seat.
|
||||
Damage []int `json:"damage"`
|
||||
// Whether each front pet died in the clash, per seat.
|
||||
Died []bool `json:"died"`
|
||||
// "reveal": Seat flipped Card off their stack (food or pet).
|
||||
// "summon": an effect put Card on top of Seat's stack.
|
||||
// "rock": Seat's pet threw rocks at Target's pet in play.
|
||||
// "clash": the pets in play traded blows.
|
||||
// "eat": Seat's pet ate apples; Bonus is its new total.
|
||||
Type string `json:"type"`
|
||||
Seat int `json:"seat,omitempty"`
|
||||
Target int `json:"target,omitempty"`
|
||||
Card *Card `json:"card,omitempty"`
|
||||
// clash: per-seat damage totals / deaths after the exchange.
|
||||
Damage []int `json:"damage,omitempty"`
|
||||
Died []bool `json:"died,omitempty"`
|
||||
// rock: dice total rolled and the target pet's resulting state.
|
||||
Roll int `json:"roll"`
|
||||
DamageAfter int `json:"damageAfter,omitempty"`
|
||||
TargetDied bool `json:"targetDied,omitempty"`
|
||||
// eat: the pet's power bonus after eating.
|
||||
Bonus int `json:"bonus,omitempty"`
|
||||
}
|
||||
|
||||
// BattleResult is the full, public record of one round's battle.
|
||||
// BattleResult is the full, public record of one round's battle. Only what
|
||||
// was revealed appears in Events; the rest of the winner's stack stays
|
||||
// hidden.
|
||||
type BattleResult struct {
|
||||
Round int `json:"round"`
|
||||
Lineups [][]BattleUnit `json:"lineups"` // initial lineups per seat
|
||||
WastedFood [][]Card `json:"wastedFoods"` // foods with no pet beneath them, per seat
|
||||
Events []BattleEvent `json:"events"`
|
||||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||||
Trophies int `json:"trophies"` // awarded to the winner
|
||||
Round int `json:"round"`
|
||||
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||||
Events []BattleEvent `json:"events"`
|
||||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||||
Trophies int `json:"trophies"` // awarded to the winner
|
||||
}
|
||||
|
||||
// buildLineup walks a deck top-to-bottom, attaching each run of foods to the
|
||||
// next pet below it. Foods after the last pet affect nothing and are wasted.
|
||||
func buildLineup(deck []Card) (units []BattleUnit, wasted []Card) {
|
||||
var pendingFoods []Card
|
||||
for _, c := range deck {
|
||||
if c.IsFood() {
|
||||
pendingFoods = append(pendingFoods, c)
|
||||
continue
|
||||
}
|
||||
u := BattleUnit{Card: c, Foods: pendingFoods}
|
||||
for _, f := range pendingFoods {
|
||||
if f.Food == FoodApple {
|
||||
u.Bonus++
|
||||
}
|
||||
}
|
||||
pendingFoods = nil
|
||||
units = append(units, u)
|
||||
}
|
||||
return units, pendingFoods
|
||||
// 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 and waiting for a pet
|
||||
unit *BattleUnit
|
||||
beesFainted int // friendly bees fainted so far (Dog)
|
||||
delayedRocks []int // set-aside Badgers: rocks to throw when the next pet plays
|
||||
}
|
||||
|
||||
// queuedPlay is a play-time effect waiting to resolve after reveals.
|
||||
type queuedPlay struct {
|
||||
seat int
|
||||
unit *BattleUnit // the pet whose play queued this (must still be in play for eats)
|
||||
effect Effect
|
||||
badger bool // delayedRocks payout: hits EVERY active pet, own included
|
||||
}
|
||||
|
||||
// resolveBattle simulates the battle from the players' arranged decks,
|
||||
// records the event log, awards trophies, and moves to PhaseBattle.
|
||||
//
|
||||
// Combat: the two front pets deal their full Power to each other
|
||||
// simultaneously as damage markers. A pet with Damage >= Power dies. Since
|
||||
// remaining health never exceeds Power, at least one pet dies every clash,
|
||||
// so the loop always terminates (until effects say otherwise).
|
||||
// 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 roll dice (faces
|
||||
// 0,0,1,1,2,2) and 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. Garlic prevents 1 damage from every attack that
|
||||
// hits its pet; a clash that deals no damage to anyone ends the battle as a
|
||||
// stalemate.
|
||||
func (g *Game) resolveBattle() {
|
||||
res := &BattleResult{
|
||||
Round: g.Round,
|
||||
Lineups: make([][]BattleUnit, len(g.Players)),
|
||||
WastedFood: make([][]Card, len(g.Players)),
|
||||
WinnerSeat: -1,
|
||||
}
|
||||
live := make([][]BattleUnit, len(g.Players)) // working copies
|
||||
front := make([]int, len(g.Players)) // index of each seat's front pet
|
||||
n := len(g.Players)
|
||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n)}
|
||||
sides := make([]*battleSide, n)
|
||||
for _, p := range g.Players {
|
||||
units, wasted := buildLineup(p.Deck)
|
||||
res.Lineups[p.Seat] = units
|
||||
res.WastedFood[p.Seat] = wasted
|
||||
live[p.Seat] = append([]BattleUnit(nil), units...)
|
||||
sides[p.Seat] = &battleSide{stack: append([]Card(nil), p.Deck...)}
|
||||
res.StackSizes[p.Seat] = len(p.Deck)
|
||||
}
|
||||
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
||||
|
||||
summon := func(seat int, c Card) {
|
||||
s := sides[seat]
|
||||
s.stack = append([]Card{c}, s.stack...)
|
||||
emit(BattleEvent{Type: "summon", Seat: seat, Card: &c})
|
||||
}
|
||||
|
||||
// Two-player combat. Effects and >2 player battle formats come later;
|
||||
// the surrounding state (lineups, events) is already per-seat.
|
||||
a, b := 0, 1
|
||||
for front[a] < len(live[a]) && front[b] < len(live[b]) {
|
||||
ua, ub := &live[a][front[a]], &live[b][front[b]]
|
||||
ua.Damage += ub.Power()
|
||||
ub.Damage += ua.Power()
|
||||
ev := BattleEvent{
|
||||
// faint fires the unit's faint effects (its own and its perk's) in
|
||||
// effect order, and tracks fainted bees.
|
||||
faint := func(seat int, u *BattleUnit) {
|
||||
if isBee(u.Card) {
|
||||
sides[seat].beesFainted++
|
||||
}
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerFaint {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionSummonTop:
|
||||
target := seat
|
||||
if e.Target == "enemy" {
|
||||
target = (seat + 1) % n
|
||||
}
|
||||
for range e.count() {
|
||||
if e.Card == "bee" {
|
||||
summon(target, g.newBee())
|
||||
} else {
|
||||
summon(target, g.newApple())
|
||||
}
|
||||
}
|
||||
case ActionRecycleApples:
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f)
|
||||
recycled++
|
||||
}
|
||||
}
|
||||
case ActionDelayedRocks:
|
||||
sides[seat].delayedRocks = append(sides[seat].delayedRocks, e.count())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionEatApple:
|
||||
for range e.count() {
|
||||
u.Foods = append(u.Foods, g.newApple())
|
||||
u.Bonus++
|
||||
}
|
||||
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: u.Bonus})
|
||||
case ActionSummonTop:
|
||||
for range e.count() {
|
||||
if e.Card == "bee" {
|
||||
summon(seat, g.newBee())
|
||||
} else {
|
||||
summon(seat, g.newApple())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// throwRocks rolls `dice` rock dice against one target's pet, handling
|
||||
// prevention, death (with faint), and hurt. Reports whether it killed.
|
||||
throwRocks := func(from, target, dice int) (killed bool) {
|
||||
tu := sides[target].unit
|
||||
if tu == nil {
|
||||
return false
|
||||
}
|
||||
roll := 0
|
||||
for range dice {
|
||||
roll += g.rollRockDie()
|
||||
}
|
||||
dealt := tu.takeHit(roll)
|
||||
died := !tu.Alive()
|
||||
emit(BattleEvent{
|
||||
Type: "rock", Seat: from, Target: target, Roll: roll,
|
||||
DamageAfter: tu.Damage, TargetDied: died,
|
||||
})
|
||||
if died {
|
||||
faint(target, tu)
|
||||
sides[target].unit = nil
|
||||
return true
|
||||
}
|
||||
if dealt > 0 {
|
||||
hurt(target, tu)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The exchange loop terminates: every clash kills at least one pet or
|
||||
// is a detected stalemate, and summons 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
|
||||
for seat, s := range sides {
|
||||
for s.unit == nil && len(s.stack) > 0 {
|
||||
c := s.stack[0]
|
||||
s.stack = s.stack[1:]
|
||||
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c})
|
||||
if c.IsFood() {
|
||||
s.pending = append(s.pending, c)
|
||||
continue
|
||||
}
|
||||
u := &BattleUnit{Card: c, Foods: s.pending}
|
||||
for _, f := range s.pending {
|
||||
if f.Food == FoodApple {
|
||||
u.Bonus++
|
||||
}
|
||||
}
|
||||
s.pending = nil
|
||||
s.unit = u
|
||||
// Set-aside Badgers pay out when the next pet plays,
|
||||
// before that pet's own play effects.
|
||||
for _, dice := range s.delayedRocks {
|
||||
plays = append(plays, queuedPlay{seat: seat, unit: u, badger: true,
|
||||
effect: Effect{Action: ActionThrowRock, Count: dice}})
|
||||
}
|
||||
s.delayedRocks = nil
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger == TriggerPlay {
|
||||
plays = append(plays, queuedPlay{seat: seat, unit: u, effect: e})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Rocks land before the clash and can faint
|
||||
// pets outright.
|
||||
rockDeath := false
|
||||
for _, q := range plays {
|
||||
switch q.effect.Action {
|
||||
case ActionThrowRock:
|
||||
if q.badger {
|
||||
// At EACH active pet, the owner's own included.
|
||||
for seat := range sides {
|
||||
if throwRocks(q.seat, seat, q.effect.count()) {
|
||||
rockDeath = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
target := -1
|
||||
for off := 1; off < n; off++ {
|
||||
cand := (q.seat + off) % n
|
||||
if sides[cand].unit != nil {
|
||||
target = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
if target >= 0 && throwRocks(q.seat, target, q.effect.count()) {
|
||||
rockDeath = true
|
||||
}
|
||||
}
|
||||
case ActionEatApple:
|
||||
// Skip if the eater already died (e.g. to Badger rocks).
|
||||
if sides[q.seat].unit != q.unit {
|
||||
continue
|
||||
}
|
||||
count := q.effect.count()
|
||||
if q.effect.Per == PerFaintedBees {
|
||||
count *= sides[q.seat].beesFainted
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
if rockDeath {
|
||||
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
|
||||
dealtA := ua.takeHit(ub.Power())
|
||||
dealtB := ub.takeHit(ua.Power())
|
||||
emit(BattleEvent{
|
||||
Type: "clash",
|
||||
Units: []int{front[a], front[b]},
|
||||
Damage: []int{ua.Damage, ub.Damage},
|
||||
Died: []bool{!ua.Alive(), !ub.Alive()},
|
||||
})
|
||||
if ua.Alive() && ub.Alive() && dealtA == 0 && dealtB == 0 {
|
||||
break // stalemate: nothing can ever change
|
||||
}
|
||||
res.Events = append(res.Events, ev)
|
||||
if !ua.Alive() {
|
||||
front[a]++
|
||||
}
|
||||
if !ub.Alive() {
|
||||
front[b]++
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trophies := 1
|
||||
if g.Round == MaxRounds {
|
||||
trophies = 2
|
||||
// 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
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case front[a] < len(live[a]):
|
||||
res.WinnerSeat = a
|
||||
case front[b] < len(live[b]):
|
||||
res.WinnerSeat = b
|
||||
}
|
||||
if res.WinnerSeat >= 0 {
|
||||
res.Trophies = trophies
|
||||
g.Players[res.WinnerSeat].Trophies += trophies
|
||||
res.WinnerSeat = winner
|
||||
if winner >= 0 {
|
||||
res.Trophies = 1
|
||||
if g.Round == MaxRounds {
|
||||
res.Trophies = 2
|
||||
}
|
||||
g.Players[winner].Trophies += res.Trophies
|
||||
}
|
||||
|
||||
g.Battle = res
|
||||
|
||||
Reference in New Issue
Block a user