Narrate the battle in the event log, synced to the replay step.

This commit is contained in:
Greyson Parrelli
2026-07-23 08:07:17 -04:00
parent 506c6e5b55
commit 63b64bc1d8
5 changed files with 151 additions and 26 deletions
+72 -19
View File
@@ -1,5 +1,7 @@
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 {
@@ -100,6 +102,10 @@ type BattleEvent struct {
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.
@@ -194,6 +200,8 @@ func (g *Game) resolveBattle() {
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...)}
@@ -222,17 +230,19 @@ func (g *Game) resolveBattle() {
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})
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) {
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})
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" {
@@ -297,13 +307,13 @@ func (g *Game) resolveBattle() {
target = (seat + 1) % n
}
for range effectCount(e, s, u) {
summon(target, mintFor(e.Card))
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)
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
recycled++
}
}
@@ -334,7 +344,8 @@ func (g *Game) resolveBattle() {
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})
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)})
}
}
}
@@ -355,10 +366,11 @@ func (g *Game) resolveBattle() {
u.Foods = append(u.Foods, g.newApple())
u.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: seat, Bonus: 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))
summon(seat, mintFor(e.Card), fmt.Sprintf("%s's hurt effect", u.Card.Name))
}
case ActionShieldSelf:
u.Shield += e.count()
@@ -381,12 +393,26 @@ func (g *Game) resolveBattle() {
}
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,
DamageAfter: tu.Damage, TargetDied: died, Text: rockTxt,
})
if blocked {
emit(BattleEvent{Type: "shield", Seat: target})
emit(BattleEvent{Type: "shield", Seat: target,
Text: fmt.Sprintf("%s blocks the rocks with a shield.", tu.Card.Name)})
}
if died {
faint(target, tu)
@@ -425,7 +451,8 @@ func (g *Game) resolveBattle() {
c := s.stack[0]
s.stack = s.stack[1:]
if c.IsFood() {
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c})
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
}
@@ -437,7 +464,11 @@ func (g *Game) resolveBattle() {
}
// Pets carry their starting bonus (foods + auras) in the
// reveal event so clients can display it directly.
emit(BattleEvent{Type: "reveal", Seat: seat, Card: &c, Bonus: u.Bonus})
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
@@ -543,7 +574,8 @@ func (g *Game) resolveBattle() {
tu.Bonus -= appleCount(tu.Foods)
tu.Foods = nil
died := !tu.Alive()
emit(BattleEvent{Type: "strip", Seat: q.seat, Target: t, TargetDied: died})
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
@@ -573,7 +605,8 @@ func (g *Game) resolveBattle() {
tu.Bonus -= moved
q.unit.Bonus += moved
died := !tu.Alive()
emit(BattleEvent{Type: "steal", Seat: q.seat, Target: t, Count: moved, TargetDied: died})
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
@@ -588,11 +621,12 @@ func (g *Game) resolveBattle() {
break
}
ts.stack = ts.stack[1:]
emit(BattleEvent{Type: "mill", Seat: t, Card: &top})
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))
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)
@@ -601,7 +635,8 @@ func (g *Game) resolveBattle() {
q.unit.Foods = append(q.unit.Foods, g.newApple())
q.unit.Bonus++
}
emit(BattleEvent{Type: "eat", Seat: q.seat, Bonus: 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)})
}
}
}
@@ -622,16 +657,29 @@ func (g *Game) resolveBattle() {
if dealtB > 0 && ua.hasKnockout() {
ub.Damage = max(ub.Damage, ub.Power())
}
clashTxt := fmt.Sprintf("%s's %s and %s's %s trade blows.",
pname(0), ua.Card.Name, pname(1), ub.Card.Name)
switch da, db := !ua.Alive(), !ub.Alive(); {
case da && db:
clashTxt += " Both faint."
case da:
clashTxt += fmt.Sprintf(" %s faints.", ua.Card.Name)
case db:
clashTxt += fmt.Sprintf(" %s faints.", ub.Card.Name)
}
emit(BattleEvent{
Type: "clash",
Damage: []int{ua.Damage, ub.Damage},
Died: []bool{!ua.Alive(), !ub.Alive()},
Text: clashTxt,
})
if blockedA {
emit(BattleEvent{Type: "shield", Seat: 0})
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})
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
@@ -676,6 +724,11 @@ func (g *Game) resolveBattle() {
g.Battle = res
g.Phase = PhaseBattle
if winner < 0 {
g.logf(-1, "⚔️", "Round %d battle ends in a draw.", g.Round)
} else {
g.logf(winner, "⚔️", "%s wins the round %d battle (+%d🏆).", pname(winner), g.Round, res.Trophies)
}
for _, p := range g.Players {
p.Ready = false
}