From d7fe32d89369871717fd2d420d74b35202a6e0e9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 20:32:41 -0700 Subject: [PATCH] =?UTF-8?q?D&D:=20class-balance=20Phase=203=20=E2=80=94=20?= =?UTF-8?q?off-tier=20caster=20lift=20(druid=20+=20sorcerer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gogobee_class_balance.md | 37 ++++++++++++-- internal/plugin/dnd_class_balance_test.go | 61 +++++++++++++++++++++++ internal/plugin/dnd_passives.go | 18 +++++-- internal/plugin/dnd_xp_test.go | 7 ++- 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/gogobee_class_balance.md b/gogobee_class_balance.md index b61e99a..f6f1550 100644 --- a/gogobee_class_balance.md +++ b/gogobee_class_balance.md @@ -122,10 +122,39 @@ HP-remaining, and near-death-rate are logged as diagnostics, not asserted. through with weapon dice. Observed worst in-tier cell after tuning: L3/T2 at ~26pp, L1/T1 at ~24pp, L5/T3 at ~20pp; the 35pp band gives ~9pp Monte-Carlo headroom at 200 trials/cell. **← current** -- **Phase 3 (if needed) — second-order.** Subclass-vs-subclass spread within a - class; the Paladin/Rogue MAD question if the data shows it bites. The L7/T5 - cell (47pp spread, *off-tier*) is the most obvious next target if the - underleveled-cell experience needs lifting. +- **Phase 3 — off-tier caster lift. Done.** Design steer ("relatively easy + but not too easy") narrowed the target: lift the embarrassing caster + trailers on off-tier cells (the casual player walking into a slightly + too-hard dungeon underleveled) *without* pushing the already-saturated + in-tier ceiling. Tuned levers: + - **Druid passive** (`dnd_passives.go`): the only class chassis with a + purely defensive passive (5% DR, no offense). 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** (`dnd_passives.go`): burst base 3→5. Sorcerer was + the 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 parity with + the other arcane chassis. + + Added an off-tier-trailer diagnostic (`dnd_class_balance_test.go`) — for + each (level, tier) cell where the player is below the in-tier band, the + log names the trailing class and its mean win rate. Not asserted: an + L1 walking into T2 is *supposed* to be hard, so the diagnostic is for + watching the gap, not the absolute number. + + Observed lifts: + - Druid L1/T1: 0.77 → 0.86 (+9pp). The chassis is now functional at its + intended tier. + - L2/T2 cross-class spread: 77pp → 63pp. Druid trailer 0.23 → 0.35. + - L1/T1 spread: 23pp → 14pp. + + In-tier parity assertion (35pp band on the diagonal) still passes. No + off-tier cell saturated past where it was — the lift is shape-correct. + **← current** +- **Phase 4 (if needed) — second-order.** Subclass-vs-subclass spread within a + class; the Paladin/Rogue MAD question if the data shows it bites. The L5/T4 + wild-magic Sorcerer cell (0.14 individual, off-tier) is the most obvious + next target if subclass parity needs another pass. ## 6. Tuning levers (priority order) diff --git a/internal/plugin/dnd_class_balance_test.go b/internal/plugin/dnd_class_balance_test.go index 4965b6a..f14cfe3 100644 --- a/internal/plugin/dnd_class_balance_test.go +++ b/internal/plugin/dnd_class_balance_test.go @@ -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 { diff --git a/internal/plugin/dnd_passives.go b/internal/plugin/dnd_passives.go index faffb21..105749e 100644 --- a/internal/plugin/dnd_passives.go +++ b/internal/plugin/dnd_passives.go @@ -43,7 +43,7 @@ var dndClassAbilities = map[DnDClass]DnDClassAbility{ // CombatModifiers channels the same way the original five do. ClassDruid: { Name: "Wild Resilience", - Description: "The wild lends you its toughness: incoming damage is reduced 5%.", + Description: "The wild lends you its toughness — incoming damage is reduced 5% — and a thorn surge on engage scaled by your Wisdom.", }, ClassBard: { Name: "Bardic Inspiration", @@ -137,6 +137,14 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // subclass DamageReduct riders. DamageReduct is initialized to 1.0 // by DerivePlayerStats before passives run. mods.DamageReduct *= 0.95 + // Phase 3 class-balance: druid was the only caster chassis with a + // purely defensive passive, and the off-tier numbers showed it — + // L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so + // the L1-4 druid can chip a T2 monster: WIS-scaled FlatDmgStart on + // engage. No DamageBonus rider; the chassis keeps its defensive + // identity, and the burst alone (plus the existing DR) lifts the + // off-tier cells without pushing in-tier wins past 1.0. + mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.WIS)) case ClassBard: // Phase 2 class-balance: bare +1 initiative left Bard the weakest // class chassis (T5 mean 0.48 pre-tune). Add a Ranger-tier rider @@ -149,11 +157,15 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) case ClassSorcerer: // Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's - // spellcasting stat. Floors at the flat 3 for low-CHA builds. + // spellcasting stat. Floors at the flat base for low-CHA builds. // Phase 2 class-balance: pure FlatDmgStart faded at higher tiers as // monster HP grew. Adding a 5% damage rider plus level scaling on the // burst keeps the chassis relevant past L1. - mods.FlatDmgStart += 3 + c.Level + clampNonNeg(abilityModifier(c.CHA)) + // Phase 3 class-balance: sorcerer was the second-worst off-tier caster + // (L1/T2 0.10 pre-tune), trailing mage by ~17pp despite a comparable + // stat line. Bump the burst base 3→5 to lift the L1-4 chassis without + // touching the +0.05 rider that already saturates at high tier. + mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA)) mods.DamageBonus += 0.05 case ClassWarlock: // Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack — diff --git a/internal/plugin/dnd_xp_test.go b/internal/plugin/dnd_xp_test.go index d437238..72b0d34 100644 --- a/internal/plugin/dnd_xp_test.go +++ b/internal/plugin/dnd_xp_test.go @@ -229,13 +229,16 @@ func TestApplyClassPassives(t *testing.T) { // burst now also scales with level, and Warlock picked up +1 attack. // CHA/INT are 0 in this zero-value sheet → abilityModifier = -5, // clamped to 0 by clampNonNeg. Paladin at L1 is still 4 + 0. + // Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst + // (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5 + // (5 + 1 + clamp(mod(CHA=10)=0) = 6). {ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0}, {ClassMage, 0.05, 1, false, 0, 1.0, 1, 0}, {ClassCleric, 0, 0, false, 5, 1.0, 0, 0}, {ClassRanger, 0.05, 1, false, 0, 1.0, 0, 0}, - {ClassDruid, 0, 0, false, 0, 0.95, 0, 0}, + {ClassDruid, 0, 0, false, 0, 0.95, 1, 0}, {ClassBard, 0.05, 1, false, 0, 1.0, 1, 1}, - {ClassSorcerer, 0.05, 0, false, 0, 1.0, 4, 0}, + {ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0}, {ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0}, {ClassPaladin, 0, 0, false, 0, 1.0, 4, 0}, }