combat: revive the caster sustained-cantrip floor in the turn engine

The arcane-blaster "sustained floor" passives (CantripPerRound, DamageBonus,
FlatDmgStart from casterBlasterFloor) were built for the swing-based engine
(SimulateCombat, combat_engine.go:590). But every live expedition auto-resolves
through the turn engine (autoDriveCombat -> session -> combat_turn_engine),
where casters autocast every turn and never weapon-swing -- so CantripPerRound
never fired and DamageBonus was inert. Casters fought at bare cantrip dice
(~4d10~=22 at L20) instead of their intended floor, in sim AND in prod. This is
why every caster damage dial read as a dead lever across the whole rebaseline.

Fix (combat_cmd.go): bridge the already-computed CantripPerRound into the
turn-engine damage-cantrip cast, hit-gated (only lift a cast that already
connected, so the ~35% miss variance survives and the floor isn't a guaranteed
flat hammer). Self-targeting: only Mage/Sorcerer/Warlock carry a nonzero
CantripPerRound -- martials swing (untouched), cleric/bard/druid have floor 0.

Tuning (dnd_passives.go): casterCantripBase 9 -> 3, now a live, class-specific
lever. Mage/Sorcerer take base 3; Warlock passes 0 (its bare-dice cantrip plus
a structural edge already lands it mid-band, so an added floor overshoots).
Removed the dead casterHPPerLevel rider (it inflated the truncation-fraction
denominator without adding startable HP -- a bug).

Also lands the deterministic-seeding infra (sim_seed.go + simIntN/simFloat64
threading) used to read these deltas out of the process-seed noise; prod is
byte-identical (unseeded -> package rand).

Confirmation (expedition-sim, L20 T5 dragons_lair+abyss_portal, n=250):
casters now in the 35-45 floor -- sorcerer 39, mage 38, warlock 36; martial
leaders undisturbed (rogue 68, druid 66, ranger 65, fighter 64, ... paladin 55).
This commit is contained in:
prosolis
2026-07-17 16:17:41 -07:00
parent 6e2782ac48
commit 1f62a8e842
13 changed files with 281 additions and 31 deletions

View File

@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
// Revive the arcane-blaster sustained cantrip floor in the turn engine.
// combat_engine.go:590 deals CantripPerRound flat every round, but that
// path is the swing-based engine (SimulateCombat). Auto-resolve — the
// live path for every expedition — runs the turn engine, where a caster
// autocasts and never weapon-swings, so the floor was dead code: casters
// fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip
// damage to the floor, but only when the cast already connected
// (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35%
// miss variance survives and the floor doesn't become a guaranteed flat
// hammer (that overshot to ~99%). Damage cantrips only; slot spells keep
// their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero
// CantripPerRound, so this is self-targeting.
if spell.Level == 0 && eff.EnemyDamage > 0 &&
(spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) {
if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage {
eff.EnemyDamage = floor
}
}
// §1 — redirect the heal onto the named ally. The roll is the same; only
// the body it lands on changes. This is the line that makes a cleric a
// cleric: until it existed, every heal in the engine was a self-heal, and