D&D: weighted race-balance pass — tune races to equal effective power

Flat net ability mods aren't equal *effective* power: a +1 in a stat a
build uses beats a +1 in a dump stat. Add dnd_race_balance.go, which
scores each race's mods against a 60/40 blend of per-class combat stat
priorities and class-independent non-combat utility (zone locks,
expedition harvest, skill checks, haggling).

Retune all races so their mean score across playable classes lands
within ±0.5 of the Standard Human baseline (6.0); best-fit/worst-fit
spread is kept as intentional race identity. TestRaceBalance logs the
report and asserts the rule.
This commit is contained in:
prosolis
2026-05-14 19:47:01 -07:00
parent 9f762787f6
commit 9d6192dc6a
3 changed files with 241 additions and 8 deletions

View File

@@ -0,0 +1,197 @@
package plugin
import (
"math"
"sort"
)
// Weighted race-balance model. Drives the race-mod tuning in dnd.go
// (see gogobee_dnd_design_doc and the project memory).
//
// Flat net ability mods are NOT equal *effective* power: a +1 in a stat a
// build actually uses is worth far more than a +1 in a dump stat. This
// model scores each race by weighting its mods against a blend of (a) the
// combat stat priorities of each class and (b) a class-independent
// "utility" weight for how much each stat does *outside* combat — zone
// locks, expedition harvest, skill checks, haggling. See classStatWeights
// and the combatUtilityBlend constant.
//
// Every weight block is normalized to sum to 6.0 — so a perfectly flat race
// (+1 to every stat) scores exactly its net mod total (6.0) under ANY
// class. A spiky race scores above baseline with a class that wants its
// high stats, below with one that doesn't. The best-fit score is therefore
// the race's realistic effective-power ceiling.
// ability score indices into the [6]int Mods block.
const (
statSTR = 0
statDEX = 1
statCON = 2
statINT = 3
statWIS = 4
statCHA = 5
)
func statIndex(name string) int {
switch name {
case "STR":
return statSTR
case "DEX":
return statDEX
case "CON":
return statCON
case "INT":
return statINT
case "WIS":
return statWIS
case "CHA":
return statCHA
}
return -1
}
// normalizeWeights scales a raw weight block so it sums to 6.0 — the point
// where a flat +1-to-all race scores its net mod total (6.0) under it.
func normalizeWeights(w [6]float64) [6]float64 {
var sum float64
for _, v := range w {
sum += v
}
if sum == 0 {
return w
}
for i := range w {
w[i] = w[i] * 6 / sum
}
return w
}
// combatStatWeights returns the per-class *combat* weight block. Raw role
// weights, before normalization:
//
// primary attack stat (PrimaryA) ... 4
// secondary stat (PrimaryB) ........ 2
// CON (survivability) .............. 3
// everything else (saves/skills) ... 1
//
// CON is weighted highly for every class — HP keeps you in the fight
// regardless of build. Where a class's PrimaryA/PrimaryB *is* CON, the
// higher weight wins (max, not sum).
func combatStatWeights(c DnDClassInfo) [6]float64 {
var w [6]float64
for i := range w {
w[i] = 1 // floor: saves, skills, utility
}
if w[statCON] < 3 {
w[statCON] = 3 // survivability matters for every class
}
if i := statIndex(c.PrimaryB); i >= 0 && w[i] < 2 {
w[i] = 2
}
if i := statIndex(c.PrimaryA); i >= 0 && w[i] < 4 {
w[i] = 4
}
return normalizeWeights(w)
}
// utilityStatWeights is the class-independent *non-combat* weight block:
// how much each stat does outside a fight. Derived from a codebase audit
// of where ability mods drive non-combat outcomes — zone-navigation stat
// locks (zone_graph_nav.go), expedition harvest yields (dnd_expedition_
// harvest.go), night/transit checks, the 11-skill system with its NPC
// cost-refund hooks (dnd_skills.go), and CHA hospital-bill haggling
// (adventure_hospital.go). Raw weights, before normalization:
//
// STR ... 2.0 (one zone lock, mining harvest)
// DEX ... 3.0 (two zone locks, fishing harvest, AC)
// CON ... 5.0 (two locks, short-rest healing, level-up HP, night checks)
// INT ... 3.5 (one lock, Arcana/Investigation, scavenge + essence harvest)
// WIS ... 5.0 (one lock, Perception/Insight, healing, forage + commune, night)
// CHA ... 4.0 (three locks, Persuasion/Intimidation/Deception, haggling)
func utilityStatWeights() [6]float64 {
return normalizeWeights([6]float64{2.0, 3.0, 5.0, 3.5, 5.0, 4.0})
}
// combatUtilityBlend is how much a character's effective power comes from
// combat vs. everything else (zones, expeditions, harvest, skills,
// haggling). gogobee is combat-forward but not combat-only.
const combatUtilityBlend = 0.6
// classStatWeights is the blended per-class weight block used for scoring:
// combatUtilityBlend of the class's combat weights plus the remainder of
// the class-independent utility weights. Both components are normalized to
// sum 6.0, so the blend is too — a flat +1-to-all race still scores exactly
// 6.0 under every class.
func classStatWeights(c DnDClassInfo) [6]float64 {
combat := combatStatWeights(c)
utility := utilityStatWeights()
var w [6]float64
for i := range w {
w[i] = combatUtilityBlend*combat[i] + (1-combatUtilityBlend)*utility[i]
}
return w
}
// raceScore is the weighted effective-power of a race under one class:
// Σ mod[i] * weight[i].
func raceScore(mods [6]int, w [6]float64) float64 {
var s float64
for i := 0; i < 6; i++ {
s += float64(mods[i]) * w[i]
}
return s
}
// raceBalanceBaseline is the Standard Human score under every class:
// +1 to all six stats × weights summing to 6.0.
const raceBalanceBaseline = 6.0
// raceBalance is one race's result in the weighted balance pass.
type raceBalance struct {
Race DnDRace
BestClass DnDClass
BestScore float64 // effective-power ceiling (best-fit class)
WorstClass DnDClass
WorstScore float64 // effective-power floor (worst-fit class)
AvgScore float64 // mean across all playable classes
}
// Delta is the race's best-fit score minus the Standard Human baseline.
// Positive = over-performs a flat +1-to-all race at its optimal build.
func (rb raceBalance) Delta() float64 { return rb.BestScore - raceBalanceBaseline }
// computeRaceBalance scores every race's mods against every playable class
// and reports its best-fit, worst-fit, and average effective power.
// Results are sorted by Delta descending — biggest over-performers first.
func computeRaceBalance() []raceBalance {
out := make([]raceBalance, 0, len(dndRaces))
for _, ri := range dndRaces {
rb := raceBalance{Race: ri.Key, WorstScore: math.Inf(1)}
var total float64
var n int
first := true
for _, ci := range dndClasses {
if !ci.Playable {
continue
}
s := raceScore(ri.Mods, classStatWeights(ci))
if first || s > rb.BestScore {
rb.BestScore, rb.BestClass = s, ci.Key
}
if s < rb.WorstScore {
rb.WorstScore, rb.WorstClass = s, ci.Key
}
total += s
n++
first = false
}
if n > 0 {
rb.AvgScore = total / float64(n)
}
out = append(out, rb)
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].Delta() > out[j].Delta()
})
return out
}