From d1c067452e2cf7598e482cbc36e5e5f733af38bb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:02:28 -0700 Subject: [PATCH] Review fixes: align party enemy-HP scaling in the !fight entry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/combat_cmd.go | 11 ++++++++++- internal/plugin/combat_engine_party.go | 22 +++++++++++++++++----- internal/plugin/combat_turn_engine.go | 6 +----- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index c269e09..d930520 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -101,7 +101,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { if 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 // 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() { 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 // before anyone is asked to act, so the opening block narrates the hit // rather than showing its damage with no explanation. diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go index 0b3e4d3..45a4930 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -369,6 +369,22 @@ func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int { 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 // 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 @@ -383,11 +399,7 @@ func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int { func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult, target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool { - swings := enemyActionsThisRound(st) - reuseFirst := !abilityDealtDamage - if abilityDealtDamage { - swings-- - } + swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage) for k := 0; k < swings; k++ { tgt := target sw, sd, sp := petWhiff, petDeflect, sporeMiss diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 139287d..e5ade57 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -673,11 +673,7 @@ func (te *turnEngine) stepEnemyTurn() { // 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 // that collapses to the old "the ability stood in for the attack" skip. - actions := enemyActionsThisRound(te.st) - reuseFirstTarget := !abilityDealtDamage - if abilityDealtDamage { - actions-- - } + actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage) for a := 0; a < actions; a++ { if !(reuseFirstTarget && a == 0) { tgt, alive := te.enemyTarget()