Review follow-up on the caster-floor rebaseline: the survival-half doc in
casterBlasterFloor described an HP add the code never makes (Defense only),
and the pre-existing Druid *0.95 rider still read as a damage cut when, for a
player-defender through calcDamage, DamageReduct<1 raises damage taken. Comment
-only; no runtime change, guardrail test unaffected.
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).
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.
Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
Phase 2 Monte Carlo tuning had papered over four classes whose 5e-defining
mechanics never actually existed in the engine — only flat DamageBonus or
FlatDmgStart compensation riders that hit the right aggregate win rate.
This pass replaces the proxies with the real primitives.
New CombatModifiers fields: ExtraAttacks (additional swings/round, looped
in a new resolvePlayerSwings helper); HuntersMarkDie (per-hit Nd6 bonus,
same path as Sneak Attack); ThornLashDmg (flat counter on landed enemy
hits, fires in resolveEnemyAttack after ward/block).
Fighter L5/11/20 Extra Attack, Ranger L5 Extra Attack + Hunter's Mark
(1→4 d6 by level), Paladin L5 Extra Attack + per-hit Divine Smite
(replaces one-shot opener), Druid thorn lash. Bard College of Valor L7
"Extra Attack" subclass tier converted from +1 attack/+10% damage proxy
to the real primitive.
Post-fix T5 class-balance: martials cluster at top (Fighter/Rogue 0.90,
Ranger 0.89, Paladin 0.88), casters keep their relative ordering, spread
14pp top-to-bottom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the 'fairly breezy with some death' target the user picked
for Phase 5. Five-piece ship; Phase 1 matrix lands T1 88%, T2 74%,
T4 72%, T5 ~57% in or above band. T3 remains the design hump at
~45% (manor 39, underforge 47) — Wraith promotion to elite was
already done in Phase 4-B, the remaining standard-pool deaths are
the irreducible part of T3.
Pieces:
1. computeMaxHP × 1.5 (phase5BHPMult in dnd.go). Uniform across
class/level so the class-balance harness's in-tier parity
assertion stays green. Bootstrap (bootstrap_phase5b_hp.go)
refreshes hp_max for existing characters at startup;
idempotent via db.JobCompleted. hp_current is bumped by the
same delta so a full-HP character stays at full.
2. applyPhase5BPlayerFloor (dnd_combat.go): +3 AC, +3 AttackBonus,
+3 weapon.MagicBonus (damage). Applied at the END of
applyDnDEquipmentLayer (after computeArmorAC's AC override)
and inside buildHarnessPlayer so live and harness measurement
match.
3. Elite bracket 19 → 23 (resolveCombatInterrupt). Case order
puts Elite (≥23) before Patrol (≥22) so a 23+ total prefers
the single dangerous fight. Elite is now effectively a
high-threat event reachable only via the +1-per-20-threat-
above-40 mod — Phase 4-B's elite-pool monsters still appear,
just less often.
4. dailyThreatDrift base 3 → 1. Slows the threat clock so
players have the days they need before threat tips zones
into the new 23+ elite band.
5. applyDailyBurn default → 50% (phase5BDailyBurnRatePct). Also
applied in the temporal-override branch in
dnd_expedition_cycle.go so tidal / unraveling days scale by
the same 0.5× — otherwise those days would be
disproportionately harsh against the new baseline.
The harness's expedition_balance.go reads phase5BDailyBurnRatePct
as the default-burn fallback when the override knob is zero, so
Phase 1 matrix measurements now reflect what live players
experience.
Test debt: 13 pinned-numbers unit tests across combat_stats_test,
dnd_test, dnd_xp_test, dnd_equipment_profiles_test,
dnd_expedition_supplies_test, dnd_expedition_cycle_test,
dnd_expedition_extract_test, dnd_expedition_region_cmd_test,
dnd_expedition_combat_test, dnd_expedition_threat_test,
dnd_expedition_temporal_test, expedition_balance_test were
pinning pre-Phase-5-B baselines; updated with comments noting
the cause. Class-balance suite stayed green (uniform buff
preserves spread).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design steer from user — "relatively easy but not too easy" — narrowed the
target: lift the embarrassing caster trailers on off-tier cells (a casual
player walking into a slightly too-hard dungeon underleveled) without
pushing the already-saturated in-tier ceiling.
Levers:
- Druid passive: was the only chassis with a purely defensive passive
(5% DR, no offense), and it read it — L1/T1 mean 0.77 (lowest at the
entry tier), L1/T2 0.04. Added a level + WIS-scaled FlatDmgStart burst,
same shape as the Phase-2 Bard/Mage/Warlock pass. Kept the DR; no
DamageBonus rider so high-tier ceilings stay flat.
- Sorcerer passive: burst base 3→5. Sorcerer was second-worst caster
off-tier (L1/T2 0.10 vs Mage 0.27 pre-tune) despite a comparable stat
line; the bump pulls it toward arcane-chassis parity.
Observed lifts:
- Druid L1/T1: 0.77 → 0.86 (+9pp) — chassis now functional at its
intended tier
- L2/T2 cross-class spread: 77pp → 63pp; druid trailer 0.23 → 0.35
- L1/T1 spread: 23pp → 14pp
Off-tier diagnostic: added a focused log to TestClassBalance_Phase1_FullMatrix
that names the trailing class at each off-tier (lvl, tier) cell. Not
asserted — L1 in T2 is *supposed* to be hard, so the diagnostic is for
watching the gap, not the absolute number.
In-tier parity assertion (35pp band on the diagonal) still passes;
TestApplyClassPassives updated for the new druid/sorcerer FlatDmgStart
values; full plugin -short suite clean.
The Phase 1 per-class-mean summary was hiding the truth — most cells are
floor/ceiling-saturated (L10+ pinned at 1.0, L1-4 caster cells at high
tier pinned at 0.0), so means barely budge when you tune passives. Added
a per-(level, tier) cross-class spread diagnostic to the matrix log,
then tuned with the levers from doc §6 in priority order:
1. Class passives (dnd_passives.go) — caster trailers (Bard, Mage,
Warlock, Sorcerer) gained level + casting-stat-scaled FlatDmgStart
bursts so the L1-4 chassis isn't a quarterstaff + one weak spell
against a T2-T3 monster; small DamageBonus riders (Mage/Bard/
Sorcerer/Rogue +5%, Warlock 10→12%) and +1 attack for Bard/Warlock
close the steady-DPS gap. Added clampNonNeg so ability-mod-scaled
additions never go negative on sub-10-stat sheets.
2. Subclass L5 tiers (dnd_subclass_combat.go) — the three Sorcerer L5
picks (Wild/Storm/Draconic) and Warlock Great Old One were defense-
only or near-inert pre-tune; each gained a small bite (DamageBonus
+0.10, or a FlatDmgStart burst for Storm) so the L5 chassis can press
through a T4 monster.
Parity band locked in TestClassBalance_Phase1_FullMatrix: cross-class
spread ≤ 35pp on the in-tier diagonal — (level, tier) cells where the
level is appropriate for the tier (T1: L1-4, T2: L3-7, T3: L5-10, T4:
L7-15, T5: L10-20). Off-tier cells (L1 mage at T3 dungeon etc.) are
still logged but not asserted: those are level-vs-tier mismatches and
casters at L1-4 can't muscle through a T3 monster on a single L1-slot
spell the way martials muscle through with weapon dice. Worst in-tier
cell after tuning: ~26pp at L3/T2. The 35pp band gives ~9pp Monte-Carlo
headroom over the worst signal at 200 trials/cell.
TestApplyClassPassives expectations updated to match the new passives.
Phase 0 spike still green, full plugin suite (-short) clean.
Adds signature class passives for Druid/Bard/Sorcerer/Warlock/Paladin in
dnd_passives.go, each riding an existing CombatModifiers channel so a
playable caster is no longer mechanically empty: Druid Wild Resilience
(damage reduction), Bard Bardic Inspiration (initiative), Sorcerer Innate
Sorcery (CHA-scaled pre-combat burst), Warlock Agonizing Blast (+10%
damage), Paladin Divine Smite (level-scaled burst).
Adds 15 subclasses (3 per caster, registry now 30) using canonical 5e
archetype names, with passive-only L5/7/10/15 effects in
applySubclassPassives — consistent with the majority of the original
fifteen, no new resource pools or active abilities. subclassesForClass is
now non-empty for casters, so the L5 !subclass prompt and sheet display
light up through the existing generic plumbing.
Tests: TestApplyClassPassives extended to all ten classes and the new
channels; TestSubclassRegistry_* updated to expect 30 entries across all
ten classes.