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

@@ -23,7 +23,6 @@ package plugin
import (
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/flavor"
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
rollFn func() int,
) (CombatInterruptKind, int) {
if rollFn == nil {
rollFn = func() int { return rand.IntN(20) + 1 }
rollFn = func() int { return simIntN(20) + 1 }
}
r := rollFn()
mod := tier
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
if tier < 1 {
tier = 1
}
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
dmg := 1 + simIntN(4) + m.AttackBonus/2
floor := tier
if floorOverride >= 0 {
floor = floorOverride
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
return
}
chance := rollPatrolChance(exp.ThreatLevel)
if chance <= 0 || rand.Float64() > chance {
if chance <= 0 || simFloat64() > chance {
return
}
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)