Files
gogobee/internal/plugin/combat_engine_party.go
prosolis 055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00

926 lines
34 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 plugin
import (
"math/rand/v2"
"sort"
)
// ── N-body auto-resolve ──────────────────────────────────────────────────────
//
// The auto-resolve engine seats a roster, exactly as the turn-based engine has
// since P3. `SimulateCombat` is the one-seat case and nothing more: for a solo
// roster every short-circuit below collapses to the pre-roster code path and
// the engine draws from the RNG in precisely the pre-roster order. That is what
// keeps `TestCombatCharacterization` byte-identical and the d8prereq_corpus
// baselines comparable. If the golden moves, solo balance moved — stop.
//
// The invariants the solo path rests on, all of them mirrored from P3:
//
// - enemyTargetSeat draws nothing for a one-seat roster (there is only one
// target), so the enemy's choice costs no randomness.
// - the initiative loop draws one player roll then one enemy roll, which is
// the pre-roster order; ties go to the player, as `playerInit >= enemyInit`
// always did.
// - the per-seat loops run exactly once.
//
// Per-actor state (poison, charges, rage, wards) follows the `combatState`
// cursor, so the resolution primitives need no changes: they already read
// `st.c` — the turn engine has called them that way since P3.
// PartyCombatResult is one fight, seen from every seat at once. The fight-scoped
// fields (the enemy, the round count, the event log) are shared; `Seats` holds
// the per-character view, and `Seats[i]` is a complete `CombatResult` so a solo
// caller can take `Seats[0]` and be handed exactly what `SimulateCombat` always
// returned.
type PartyCombatResult struct {
PlayerWon bool
TimedOut bool
TotalRounds int
Events []CombatEvent
EnemyStartHP int
EnemyEntryHP int
EnemyEndHP int
Seats []CombatResult
}
// AnySurvivor reports whether at least one seat is still standing. A party can
// win a fight it did not all walk away from.
func (r PartyCombatResult) AnySurvivor() bool {
for i := range r.Seats {
if r.Seats[i].PlayerEndHP > 0 {
return true
}
}
return false
}
// simulateParty auto-resolves one enemy against a roster of N player characters.
// Production auto-resolve passes a nil rng (package global); the sim harness and
// the characterization test seed it.
func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) PartyCombatResult {
return simulatePartyWithRNG(players, enemy, phases, nil)
}
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
// scaled action economy to actually threaten each member. Solo is unchanged
// (scale 1.0), so SimulateCombat and the golden do not move.
if len(players) > 1 {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
}
enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP
}
actors := make([]*actor, len(players))
seats := make([]CombatResult, len(players))
for i := range players {
actors[i] = newActor(&players[i])
seats[i] = CombatResult{
PlayerStartHP: players[i].Stats.MaxHP,
PlayerEntryHP: actors[i].playerHP,
EnemyStartHP: enemy.Stats.MaxHP,
EnemyEntryHP: enemyStart,
}
}
st := &combatState{
actor: actors[0],
actors: actors,
enemyHP: enemyStart,
rng: rng,
}
// Holding the enemy holds it for everyone, so the control effect is
// fight-scoped: any caster who queued one arms it.
for i := range players {
if players[i].Mods.SpellEnemySkipFirst {
st.enemySkipFirst = true
}
}
// Pre-combat one-shots, grouped by kind rather than by seat. A one-seat
// roster walks these in the pre-roster order.
for i := range actors {
st.seat(i)
if st.c.Mods.SniperKillProc > 0 && st.randFloat() < st.c.Mods.SniperKillProc {
st.enemyHP = 0
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
Seat: i, Desc: "Arina",
})
seats[i].SniperKilled = true
return finalizeParty(seats, st, players, enemy)
}
}
for i := range actors {
st.seat(i)
if st.c.Mods.FlatDmgStart > 0 {
dmg := st.c.Mods.FlatDmgStart
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.enemyHP <= 0 {
return finalizeParty(seats, st, players, enemy)
}
}
}
// Queued spells. Resolved by applyPendingCast() before this runs — the
// modifiers carry the resolved damage and narrative hook.
for i := range actors {
st.seat(i)
if st.c.Mods.SpellPreDamageDesc == "" {
continue
}
dmg := st.c.Mods.SpellPreDamage
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
if resisted {
dmg = max(1, dmg/2)
}
if dmg > 0 {
st.enemyHP = max(0, st.enemyHP-dmg)
}
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
Desc: st.c.Mods.SpellPreDamageDesc,
})
if resisted {
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
}
if st.enemyHP <= 0 {
return finalizeParty(seats, st, players, enemy)
}
}
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
for _, phase := range phases {
roundsThisPhase := phase.Rounds
// Add slight variance: ±1 round for non-Decisive phases
if phase.Name != "Decisive" && roundsThisPhase > 1 {
roundsThisPhase += st.roll(2) // 0 or +1
}
for r := 0; r < roundsThisPhase; r++ {
st.round++
if simulatePartyRound(st, &enemy, &phase, seats) {
return finalizeParty(seats, st, players, enemy)
}
}
}
// Exhausted the phase clock without a kill. Tiebreak on HP percentage to
// decide the outcome — but DO NOT zero out HP on the loser. Timeout =
// retreat, not a lethal blow, so no character-death side effects fire.
//
// The party reads its fraction off the pooled roster, which for one seat is
// that seat's own fraction, i.e. the pre-roster comparison. Slight bias to
// the players on exact ties (frac >=).
var partyHP, partyMax int
for i := range st.actors {
partyHP += st.actors[i].playerHP
partyMax += players[i].Stats.MaxHP
}
playerFrac := float64(partyHP) / float64(max(1, partyMax))
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
playerWonTiebreak := playerFrac >= enemyFrac
st.seat(0)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
for i := range seats {
seats[i].TimedOut = true
}
out := finalizeParty(seats, st, players, enemy)
out.TimedOut = true
out.PlayerWon = playerWonTiebreak
for i := range out.Seats {
out.Seats[i].PlayerWon = playerWonTiebreak
}
return out
}
// combatOver reads the fight's terminal condition off HP rather than off a
// primitive's bool. resolvePlayerAttack documents why: a retaliate aura can
// drop the swinger with the enemy still standing, so `true` means "something
// decisive happened", not "the players won". For a one-seat roster this is the
// same answer the old `return true` gave.
func combatOver(st *combatState) bool {
return st.enemyHP <= 0 || !st.anyAlive()
}
// enemyTargetSeat picks who the enemy swings at. A one-seat roster draws no
// randomness — there is only one target — which is what keeps the solo RNG
// stream identical. Shared with the turn engine.
func enemyTargetSeat(st *combatState) (int, bool) {
if len(st.actors) == 1 {
return 0, st.actors[0].playerHP > 0
}
standing := make([]int, 0, len(st.actors))
for i, a := range st.actors {
if a.playerHP > 0 {
standing = append(standing, i)
}
}
if len(standing) == 0 {
return 0, false
}
return standing[st.roll(len(standing))], true
}
// eventsForSeat is the sub-log a single character is responsible for. Events the
// engine never stamped — the enemy regenerating, the phase clock running out —
// carry seat 0, so they read as the leader's. Only ever called for a party: a
// solo seat is handed the whole log by identity.
func eventsForSeat(events []CombatEvent, seat int) []CombatEvent {
out := make([]CombatEvent, 0, len(events))
for _, e := range events {
if e.Seat == seat {
out = append(out, e)
}
}
return out
}
// stampEventSeats attributes every event appended since `mark` to a seat, so
// party narration can say who did what. Seat 0 stamps a zero, which is
// `omitempty` — a solo fight's event log is byte-identical.
func stampEventSeats(st *combatState, mark, seat int) {
for i := mark; i < len(st.events); i++ {
st.events[i].Seat = seat
}
}
// roundInitiative rolls the round's turn order: every seat, then the enemy, in
// seating order — the pre-roster draw order when there is one seat. Ties favour
// the players, then the lower seat.
func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []int {
type entry struct {
seat int
init float64
}
entries := make([]entry, 0, len(st.actors)+1)
for i, a := range st.actors {
speed := float64(a.c.Stats.Speed) * phase.SpeedWeight
entries = append(entries, entry{i, speed + st.randFloat()*10 + a.c.Mods.InitiativeBias})
}
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
entries = append(entries, entry{enemySeat, enemySpeed + st.randFloat()*10})
sort.SliceStable(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.init != b.init {
return a.init > b.init
}
if (a.seat == enemySeat) != (b.seat == enemySeat) {
return b.seat == enemySeat
}
return a.seat < b.seat
})
order := make([]int, len(entries))
for i, e := range entries {
order[i] = e.seat
}
return order
}
// enemyActionsThisRound is how many attack-actions the enemy takes this round
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
// combat engines collapse to their pre-party behaviour and the characterization
// golden and d8prereq corpus do not move.
//
// A party's action budget is a *fractional expectation* (partyActionExpectation),
// realised as floor(exp) actions plus one more with probability frac(exp). The
// fraction matters because the action lever is coarse: against a party of two, an
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
// between — a per-round coin-flip for the extra action is the only way to land
// the band in the gap. The single draw is taken only for a party, so the solo RNG
// stream is untouched.
// §2(b): the budget counts the seats still STANDING, re-derived every round —
// not the seats that walked in.
//
// It used to read len(st.actors), which includes the dead. So a party that lost a
// member kept paying for them: the survivor faced a boss still swinging at
// two-player cadence, alone. That is a death spiral with the arrow pointing the
// wrong way — the moment a party is losing, the engine hits it harder — and it is
// the single nastiest thing the companion sweep turned up, because it is not a
// companion bug at all. It has been live for every human party since N3.
//
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
// actions on one.
func enemyActionsThisRound(st *combatState) int {
if livingActors(st) < 2 {
return 1
}
// The summed weight of the seats still standing — not a head count of them.
// A seat that brings half a peer buys the boss half a peer's worth of extra
// swings, and a seat that is down buys none at all.
n := livingWeight(st)
exp := partyActionExpectation(n)
base := int(exp)
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
base++
}
return base
}
// livingActors counts the seats still standing.
func livingActors(st *combatState) int {
n := 0
for _, a := range st.actors {
if a.playerHP > 0 {
n++
}
}
return n
}
// livingWeight is livingActors in the currency the enemy actually charges in: the
// summed SeatWeight of the seats still standing.
func livingWeight(st *combatState) float64 {
total := 0.0
for _, a := range st.actors {
if a.playerHP > 0 {
total += combatantWeight(a.c)
}
}
return total
}
// partyActionExpectation is the expected number of enemy attack-actions per round
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
// economy is the load-bearing lever, and it is fractional so a two-person party
// lands between soloing and a trio instead of snapping to one of two integers
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
//
// N≥3 follows 2N1, the point that gave fighter ~90% / cleric-led ~72% at
// HP ×1.15. The whole curve is monotonic by party size and never drops a
// composition below its solo clear rate — bringing a friend is never a penalty.
// It takes a fractional party size — the summed SeatWeight of the living seats,
// not a head count — and interpolates linearly between the integer knots the P8
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n1 from 3 up. Every integer
// input therefore returns exactly what it always returned, so a solo fight and a
// party of peers are byte-identical and the balance corpus is untouched. Only a
// roster of *unequal* seats lands between the knots, which is the entire point:
// a half-strength body should buy the boss half a body's worth of extra swings.
func partyActionExpectation(n float64) float64 {
switch {
case n <= 1:
return 1
case n <= 2:
// (1, 1.0) → (2, 2.4)
return 1 + 1.4*(n-1)
case n <= 3:
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
// steeper than the 2n1 line it joins.
return 2.4 + 2.6*(n-2)
default:
return 2*n - 1
}
}
// seatWeight is what one seat costs the enemy, relative to the leader.
//
// **Level, not stats.** A power score built from HP × damage would rank a cleric
// below a fighter and quietly make every mixed *human* party easier — the support
// classes would stop paying their way. Level is the class-neutral measure of what
// a body is worth, and it is exactly the axis on which the two seats that violated
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
// a level down by design.
//
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
// more than a peer: out-levelling the leader does not make the boss harder for
// everyone else.
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
if leaderLevel <= 0 || seatLevel <= 0 {
return 1
}
w := float64(seatLevel) / float64(leaderLevel)
if w > 1 {
w = 1
}
if companion {
// The layers a player accumulates and a hireling never will: no subclass, no
// magic items, no armed ability, and gear that is never Masterwork. Levels
// cannot see any of that, so it is priced here.
w *= companionSeatWeight
}
if w < seatWeightFloor {
w = seatWeightFloor
}
return w
}
const (
// companionSeatWeight is the hireling discount — the one tunable in this model.
companionSeatWeight = 0.65
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
// in the fight is always worth something to the enemy: it is one more thing that
// has to be killed.
seatWeightFloor = 0.35
)
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
// which is what every combatant built before this existed is.
func partyWeight(players []*Combatant) float64 {
total := 0.0
for _, c := range players {
total += combatantWeight(c)
}
return total
}
func combatantWeight(c *Combatant) float64 {
if c == nil {
return 0
}
if c.SeatWeight <= 0 {
return 1
}
return c.SeatWeight
}
// partyWeightOf is partyWeight for a value slice.
func partyWeightOf(players []Combatant) float64 {
total := 0.0
for i := range players {
total += combatantWeight(&players[i])
}
return total
}
// seatSetupWeight is partyWeight for a roster that is still being seated.
func seatSetupWeight(seats []CombatSeatSetup) float64 {
total := 0.0
for _, s := range seats {
total += combatantWeight(s.C)
}
return total
}
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
// fixed, each member now takes ~1 swing a round, so a longer fight is more
// cumulative exposure per member: HP became a live difficulty lever exactly the
// way P6e predicted it would once the enemy stopped swinging only once.
//
// 1.15 is where the P8 sim sweep landed the band: with 2N1 actions, a party of
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
// not the 100% faceroll a single enemy swing produced.
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
// carrying a below-median body pays proportionally less, because it brought
// proportionally less.
func partyEnemyHPScale(weight float64) float64 {
if weight <= 1 {
return 1.0
}
if weight >= 2 {
return 1.15
}
return 1.0 + 0.15*(weight-1)
}
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
// rule, so every call site (the auto-resolve engine, the party session's initial
// persist, and the per-turn rebuild) agrees on the same number.
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
}
// enemyActionPlan is the shared action budget both combat engines resolve an
// enemy turn against: how many attack-actions the enemy takes, and whether the
// first one reuses the round's already-picked target (and its already-rolled
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
// follows and none of them reuse the initial target. Both engines call this so
// the load-bearing count stays in lockstep even though their per-action bodies
// differ.
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
count = enemyActionsThisRound(st)
reuseFirst = !abilityDealtDamage
if abilityDealtDamage {
count--
}
return count, reuseFirst
}
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
// engine. A solo roster takes exactly one swing at its single seat, reusing the
// round's already-rolled target and pet procs, so its RNG stream and event log are
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
// each re-targeted at a random standing seat with that seat's own pet procs, so the
// damage is spread across the roster instead of pinning the round's first target.
//
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
// action this round, so one fewer swing follows — for solo that collapses to the
// old "the ability stands in for the attack" skip. Returns true if the fight is
// decided.
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
for k := 0; k < swings; k++ {
tgt := target
sw, sd, sp := petWhiff, petDeflect, sporeMiss
if !(reuseFirst && k == 0) {
// A fresh target for every swing past the first: re-pick among the
// standing seats and roll that seat's own pet procs. This branch never
// runs for a solo roster, so it adds no draws to the solo stream.
var alive bool
if tgt, alive = enemyTargetSeat(st); !alive {
return combatOver(st)
}
st.seat(tgt)
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
if sd {
seats[tgt].PetDeflected = true
}
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
}
st.seat(tgt)
mark := len(st.events)
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
stampEventSeats(st, mark, tgt)
if over && combatOver(st) {
return true
}
}
return false
}
// simulatePartyRound runs one round for the whole roster. Returns true if the
// fight is over.
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
phaseName := phase.Name
// Whoever the enemy is looking at this round. Chosen before the ability
// fires, because the ability lands on its target. Costs no RNG when solo.
target, alive := enemyTargetSeat(st)
if !alive {
return true
}
st.seat(target)
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
// enemy's attack for one round. Fight-scoped — holding it holds it for all.
enemyHeldThisRound := false
if st.enemySkipFirst {
st.enemySkipFirst = false
if enemyImmuneToControl(enemy, st) {
// fear_immune: the control spell can't take hold — the enemy acts
// as normal this round.
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
enemyHeldThisRound = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
}
// Monster ability: check at round start. It resolves against the enemy's
// target, which the cursor already points at.
abilityDealtDamage := enemyHeldThisRound
if enemy.Ability != nil {
if abilityFires(enemy.Ability, phaseName, st) {
mark := len(st.events)
over := applyAbility(st, st.c, enemy, phase, &seats[target])
stampEventSeats(st, mark, target)
if over && combatOver(st) {
return true
}
// Cleave and lifesteal deal damage — skip normal enemy attack this round
switch enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
}
// Poison tick from previous round. Stacked per character, so every seat
// carrying it bleeds.
for i := range st.actors {
st.seat(i)
if st.poisonTicks <= 0 {
continue
}
st.playerHP = max(0, st.playerHP-st.poisonDmg)
st.poisonTicks--
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.playerHP <= 0 && !trySave(st, st.c, phaseName) && !st.anyAlive() {
return true
}
}
// The poison may have dropped the seat the enemy had picked. Re-target
// before the swing procs, which read the target's own modifiers.
if st.actors[target].playerHP <= 0 {
if target, alive = enemyTargetSeat(st); !alive {
return true
}
}
st.seat(target)
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
petWhiff := st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
// Pet deflect: halves incoming damage to the target this round
petDeflect := st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
if petDeflect {
seats[target].PetDeflected = true
}
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
// Determine initiative. DM mood (Effusive/Hostile) biases a player's roll
// via Mods.InitiativeBias — +X means they go first more often.
for _, s := range roundInitiative(st, enemy, phase) {
if s == enemySeat {
if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
return true
}
continue
}
if st.actors[s].playerHP <= 0 {
// A seat that went down earlier this round forfeits its swing.
continue
}
st.seat(s)
mark := len(st.events)
over := resolvePlayerSwings(st, st.c, enemy, phase, &seats[s])
stampEventSeats(st, mark, s)
if over && combatOver(st) {
return true
}
}
// End-of-round, per surviving character.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
if over := endOfRoundForSeat(st, phase, &seats[i]); over {
stampEventSeats(st, mark, i)
return true
}
stampEventSeats(st, mark, i)
}
// Regenerate (monster ability): the enemy knits its wounds at the close of
// every round once the ability has armed st.enemyRegen. Fight-scoped.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// End-of-round Orc Rage backstop. The primary trigger sits at the top of
// resolvePlayerAttack so rage fires same-round when the character swings
// after taking the threshold-crossing hit. But if the enemy goes first this
// round AND next, they can be two-shot without ever getting back to that
// check. Re-checking here ensures the rage event always fires while HP > 0
// and below 50%, even if the buff goes unused.
for i := range st.actors {
st.seat(i)
mark := len(st.events)
maybeTriggerOrcRage(st, st.c, phaseName)
stampEventSeats(st, mark, i)
}
return false
}
// ── Misty's pair, shared by both engines ─────────────────────────────────────
//
// These two are the only round-end effects the turn engine did not have its own
// copy of. Everything else in endOfRoundForSeat either exists there already (the
// pet, the spiritual weapon — fired after a player action rather than at round
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
// explicit player command (the consumable auto-heal, which is `!consume`).
//
// So they are hoisted rather than re-implemented. A parallel sibling is what let
// the two win close-outs drift apart (deferred item D); this pair is now one
// list of effects with two callers, and cannot.
//
// Neither draws from the RNG unless its proc is armed, so a character with no
// Misty history rolls exactly the dice it rolled before — the sim corpus and
// combat_characterization.golden do not move.
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
// at the end of the round. Returns true when it decided the fight — that is,
// when it dropped this character, the death save failed, and nobody else is
// standing. A downed character in a still-live party returns false.
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
return false
}
dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty's crowd",
})
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
}
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
// nothing. result may be a scratch value the caller discards (the turn engine's
// is) — the durable record is the misty_heal event on the log, which is what
// seatCombatResult reads to award combat_misty_clutch.
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
return
}
healAmt := player.Mods.MistyHealAmt
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
result.MistyHealed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty",
})
}
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
// survived it — Misty's heal. Returns true when the fight is over.
//
// It exists because the turn engine's stepRoundEnd never ran either one. A
// player carrying Misty's buff lost it by fighting manually; worse, a player
// carrying her debuff *escaped* it by doing the same, which needed no discovery
// to exploit — just press !attack.
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
}
if st.playerHP <= 0 {
return false
}
mistyHeal(st, player, phaseName, result)
return false
}
// endOfRoundForSeat runs the per-character close of a round against the seat the
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
// the pre-roster order. Returns true if the fight is over.
func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult) bool {
phaseName := phase.Name
player := st.c
// Environmental hazard
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
envDmg := 2 + st.roll(5)
st.playerHP = max(0, st.playerHP-envDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
return true
}
}
// Misty crowd revenge (debuff for declining Misty)
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
}
// A character the round just killed stops acting, but the fight goes on if
// anyone else is standing.
if st.playerHP <= 0 {
return false
}
// Pet attack
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
petDmg := player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
result.PetAttacked = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Spiritual Weapon strike
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-swDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Misty heal
mistyHeal(st, player, phaseName, result)
// Consumable heal: triggers when the character drops below 60% HP. Fires up
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
// stack from inventory).
//
// Threshold is 60% rather than 50% to give low-HP classes (cleric, mage)
// more breathing room — at 50% a cleric was bleeding into the danger zone
// before the heal fired.
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
st.healChargesLeft--
healAmt := player.Mods.HealItem
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
return false
}
// finalizeParty closes the fight, giving every seat its own view. The enemy's
// numbers and the event log are shared; HP, closeness and near-death are read
// per character. For one seat this is byte-for-byte the old `finalize`.
func finalizeParty(seats []CombatResult, st *combatState, players []Combatant, enemy Combatant) PartyCombatResult {
won := st.enemyHP <= 0
enemyMax := max(1, enemy.Stats.MaxHP)
// A solo fight's seat holds the whole log, which is the same slice header the
// pre-roster engine returned. A party's seats each hold their own events, so
// the per-seat close-out (heal items burned, combat achievements) counts what
// that character actually did rather than what the party did.
solo := len(seats) == 1
for i := range seats {
a := st.actors[i]
r := &seats[i]
if solo {
r.Events = st.events
} else {
r.Events = eventsForSeat(st.events, i)
}
r.PlayerEndHP = a.playerHP
r.EnemyEndHP = st.enemyHP
r.TotalRounds = st.round
r.PlayerWon = won
playerMax := max(1, players[i].Stats.MaxHP)
if won && a.playerHP > 0 {
r.NearDeath = float64(a.playerHP) < float64(playerMax)*0.15
winnerRemaining := float64(a.playerHP) / float64(playerMax)
r.Closeness = 1.0 - winnerRemaining
} else if !won {
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
r.NearDeath = enemyRemaining < 0.15
r.Closeness = 1.0 - enemyRemaining
}
}
return PartyCombatResult{
PlayerWon: won,
TotalRounds: st.round,
Events: st.events,
EnemyStartHP: enemy.Stats.MaxHP,
EnemyEntryHP: seats[0].EnemyEntryHP,
EnemyEndHP: st.enemyHP,
Seats: seats,
}
}