mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
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
|
~26pp, L1/T1 at ~24pp, L5/T3 at ~20pp; the 35pp band gives ~9pp Monte-Carlo
|
||||||
headroom at 200 trials/cell. **← current**
|
headroom at 200 trials/cell. **← current**
|
||||||
- **Phase 3 (if needed) — second-order.** Subclass-vs-subclass spread within a
|
- **Phase 3 — off-tier caster lift. Done.** Design steer ("relatively easy
|
||||||
class; the Paladin/Rogue MAD question if the data shows it bites. The L7/T5
|
but not too easy") narrowed the target: lift the embarrassing caster
|
||||||
cell (47pp spread, *off-tier*) is the most obvious next target if the
|
trailers on off-tier cells (the casual player walking into a slightly
|
||||||
underleveled-cell experience needs lifting.
|
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)
|
## 6. Tuning levers (priority order)
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,67 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
|
|||||||
t.Logf("L%-4d %s", lvl, line)
|
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.
|
// Harness-broken gates first.
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if r.Tier == 1 && r.WinRate() == 0 {
|
if r.Tier == 1 && r.WinRate() == 0 {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ var dndClassAbilities = map[DnDClass]DnDClassAbility{
|
|||||||
// CombatModifiers channels the same way the original five do.
|
// CombatModifiers channels the same way the original five do.
|
||||||
ClassDruid: {
|
ClassDruid: {
|
||||||
Name: "Wild Resilience",
|
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: {
|
ClassBard: {
|
||||||
Name: "Bardic Inspiration",
|
Name: "Bardic Inspiration",
|
||||||
@@ -137,6 +137,14 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
||||||
// by DerivePlayerStats before passives run.
|
// by DerivePlayerStats before passives run.
|
||||||
mods.DamageReduct *= 0.95
|
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:
|
case ClassBard:
|
||||||
// Phase 2 class-balance: bare +1 initiative left Bard the weakest
|
// Phase 2 class-balance: bare +1 initiative left Bard the weakest
|
||||||
// class chassis (T5 mean 0.48 pre-tune). Add a Ranger-tier rider
|
// 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))
|
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||||
case ClassSorcerer:
|
case ClassSorcerer:
|
||||||
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
// 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
|
// Phase 2 class-balance: pure FlatDmgStart faded at higher tiers as
|
||||||
// monster HP grew. Adding a 5% damage rider plus level scaling on the
|
// monster HP grew. Adding a 5% damage rider plus level scaling on the
|
||||||
// burst keeps the chassis relevant past L1.
|
// 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
|
mods.DamageBonus += 0.05
|
||||||
case ClassWarlock:
|
case ClassWarlock:
|
||||||
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
||||||
|
|||||||
@@ -229,13 +229,16 @@ func TestApplyClassPassives(t *testing.T) {
|
|||||||
// burst now also scales with level, and Warlock picked up +1 attack.
|
// burst now also scales with level, and Warlock picked up +1 attack.
|
||||||
// CHA/INT are 0 in this zero-value sheet → abilityModifier = -5,
|
// CHA/INT are 0 in this zero-value sheet → abilityModifier = -5,
|
||||||
// clamped to 0 by clampNonNeg. Paladin at L1 is still 4 + 0.
|
// 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},
|
{ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0},
|
||||||
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
||||||
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
||||||
{ClassRanger, 0.05, 1, false, 0, 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},
|
{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},
|
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
|
||||||
{ClassPaladin, 0, 0, false, 0, 1.0, 4, 0},
|
{ClassPaladin, 0, 0, false, 0, 1.0, 4, 0},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user