Review fixes: align party enemy-HP scaling in the !fight entry path

The P8 diff scaled the enemy's max HP ×1.15 for parties at persist and
per-turn rebuild, but the !fight command's own template stayed unscaled:
the entry banner reported the pre-scale HP, and the opening-round settle
resolved the enemy against the wrong MaxHP ceiling (regen clamp, bloodied
threshold). Mirror the scalar for the banner and align the in-memory
template before the settle. Solo scales by 1.0, so it is untouched.

Also extract enemyActionPlan() so both combat engines share the one
load-bearing action-count computation instead of duplicating it.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 13:02:28 -07:00
parent 0d18cea59a
commit d1c067452e
3 changed files with 28 additions and 11 deletions

View File

@@ -101,7 +101,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if refusal != "" { if refusal != "" {
return p.replyDM(ctx, refusal) return p.replyDM(ctx, refusal)
} }
enemyHP := enemy.Stats.MaxHP // The persisted session scales enemy HP for a party (solo scales by 1.0, so
// this is a no-op there); mirror it here so the entry banner and the opening
// round resolve against the same ceiling startPartyCombatSession persisted and
// the rebuilt rounds use.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// per seat onto the session and its participant rows, so they survive the // per seat onto the session and its participant rows, so they survive the
@@ -130,6 +134,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if sess.IsParty() { if sess.IsParty() {
players := seatCombatants(seats) players := seatCombatants(seats)
// Align the in-memory template with the scaled HP persisted above, so the
// opening-round settle resolves the enemy against the same MaxHP the
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
// already happened off the unscaled value, so this does not double-scale.
enemy.Stats.MaxHP = enemyHP
// The enemy may have won initiative. Resolve everything the round owes // The enemy may have won initiative. Resolve everything the round owes
// before anyone is asked to act, so the opening block narrates the hit // before anyone is asked to act, so the opening block narrates the hit
// rather than showing its damage with no explanation. // rather than showing its damage with no explanation.

View File

@@ -369,6 +369,22 @@ func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize)) return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
} }
// 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 // 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 // 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 // round's already-rolled target and pet procs, so its RNG stream and event log are
@@ -383,11 +399,7 @@ func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult, func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool { target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
swings := enemyActionsThisRound(st) swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
reuseFirst := !abilityDealtDamage
if abilityDealtDamage {
swings--
}
for k := 0; k < swings; k++ { for k := 0; k < swings; k++ {
tgt := target tgt := target
sw, sd, sp := petWhiff, petDeflect, sporeMiss sw, sd, sp := petWhiff, petDeflect, sporeMiss

View File

@@ -673,11 +673,7 @@ func (te *turnEngine) stepEnemyTurn() {
// drawn — so its event stream and RNG draws are unchanged. When the ability // drawn — so its event stream and RNG draws are unchanged. When the ability
// already dealt damage it was the first action, so one fewer follows; for solo // already dealt damage it was the first action, so one fewer follows; for solo
// that collapses to the old "the ability stood in for the attack" skip. // that collapses to the old "the ability stood in for the attack" skip.
actions := enemyActionsThisRound(te.st) actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
reuseFirstTarget := !abilityDealtDamage
if abilityDealtDamage {
actions--
}
for a := 0; a < actions; a++ { for a := 0; a < actions; a++ {
if !(reuseFirstTarget && a == 0) { if !(reuseFirstTarget && a == 0) {
tgt, alive := te.enemyTarget() tgt, alive := te.enemyTarget()