D&D: class-balance Phase 3 — off-tier caster lift (druid + sorcerer)

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.
This commit is contained in:
prosolis
2026-05-14 20:32:41 -07:00
parent 76f814c0c9
commit d7fe32d893
4 changed files with 114 additions and 9 deletions

View File

@@ -277,6 +277,67 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
t.Logf("L%-4d %s", lvl, line)
}
// Phase-3 diagnostic — off-tier trailer per (level, tier). For each cell
// where the level is *below* the in-tier band (e.g. L1 at T2-T5), print the
// trailing class and its win rate. This is what Phase 3 lifts: the casual
// player who walks into a slightly-too-hard dungeon underleveled. Not
// asserted — Phase 3 tunes against the diagnostic numbers and the next
// phase decides whether to lock a soft off-tier band.
offTierLevels := map[int][]int{
2: {1, 2},
3: {1, 2, 3, 4},
4: {1, 2, 3, 4, 5},
5: {1, 2, 3, 4, 5, 7},
}
t.Logf("")
t.Logf("off-tier trailer per (level, tier) — class winrate is mean over subclasses (or single cell pre-L5):")
t.Logf("%-5s T2 T3 T4 T5", "lvl")
for _, lvl := range allLevels {
var line string
for tier := 2; tier <= 5; tier++ {
levels := offTierLevels[tier]
matched := false
for _, l := range levels {
if l == lvl {
matched = true
break
}
}
if !matched {
line += " " + " — "
continue
}
var trailerClass DnDClass
minV := 1.01
for _, ci := range dndClasses {
var sum float64
var n int
if lvl < 5 {
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
sum = r[tier].WinRate()
n = 1
}
} else {
for _, si := range subclassesForClass(ci.Key) {
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
sum += r[tier].WinRate()
n++
}
}
}
if n == 0 {
continue
}
wr := sum / float64(n)
if wr < minV {
minV, trailerClass = wr, ci.Key
}
}
line += fmt.Sprintf(" %-10s %.2f ", trailerClass, minV)
}
t.Logf("L%-4d %s", lvl, line)
}
// Harness-broken gates first.
for _, r := range results {
if r.Tier == 1 && r.WinRate() == 0 {