mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -71,13 +71,21 @@ type DnDClassInfo struct {
|
|||||||
Playable bool
|
Playable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Race mods are tuned for equal *effective* power, not equal net total.
|
||||||
|
// The weighted balance pass (dnd_race_balance.go) scores each race's mods
|
||||||
|
// against a blend of per-class combat priorities and class-independent
|
||||||
|
// non-combat utility (zone locks, harvest, skills, haggling). These blocks
|
||||||
|
// are tuned so every race's mean score across all playable classes lands
|
||||||
|
// near the Standard Human baseline of 6.0. Net mod totals vary (Elf +7,
|
||||||
|
// Orc +6) — a spiky race concentrated into high-value stats needs fewer
|
||||||
|
// points to match a flat one.
|
||||||
var dndRaces = []DnDRaceInfo{
|
var dndRaces = []DnDRaceInfo{
|
||||||
{RaceHuman, "Human", [6]int{1, 1, 1, 1, 1, 1}, "Versatile: +1 to every ability score"},
|
{RaceHuman, "Human", [6]int{1, 1, 1, 1, 1, 1}, "Versatile: +1 to every ability score"},
|
||||||
{RaceElf, "Elf", [6]int{0, 3, -1, 2, 2, 0}, "Darkvision; immune to sleep effects"},
|
{RaceElf, "Elf", [6]int{0, 3, -1, 2, 3, 0}, "Darkvision; immune to sleep effects"},
|
||||||
{RaceDwarf, "Dwarf", [6]int{2, -1, 3, 1, 2, -1}, "Poison resistance; bonus vs. underground enemies"},
|
{RaceDwarf, "Dwarf", [6]int{2, -1, 3, 1, 1, -1}, "Poison resistance; bonus vs. underground enemies"},
|
||||||
{RaceHalfling, "Halfling", [6]int{0, 3, 1, 0, 2, 0}, "Lucky: once per combat, reroll a natural 1"},
|
{RaceHalfling, "Halfling", [6]int{0, 3, 1, 0, 2, 0}, "Lucky: once per combat, reroll a natural 1"},
|
||||||
{RaceOrc, "Orc", [6]int{5, -1, 4, -1, -1, 0}, "Rage: once per combat, +50% damage for one turn"},
|
{RaceOrc, "Orc", [6]int{6, -1, 3, -1, -1, 0}, "Rage: once per combat, +50% damage for one turn"},
|
||||||
{RaceTiefling, "Tiefling", [6]int{0, 2, 0, 2, 0, 2}, "Fire resistance; bonus on CHA checks"},
|
{RaceTiefling, "Tiefling", [6]int{0, 2, 0, 1, 0, 3}, "Fire resistance; bonus on CHA checks"},
|
||||||
{RaceHalfElf, "Half-Elf", [6]int{0, 2, 0, 1, 1, 2}, "Two bonus skill proficiencies"},
|
{RaceHalfElf, "Half-Elf", [6]int{0, 2, 0, 1, 1, 2}, "Two bonus skill proficiencies"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
197
internal/plugin/dnd_race_balance.go
Normal file
197
internal/plugin/dnd_race_balance.go
Normal 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
|
||||||
|
}
|
||||||
@@ -106,21 +106,49 @@ func TestComputeAC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyRaceMods(t *testing.T) {
|
func TestApplyRaceMods(t *testing.T) {
|
||||||
// Elf: STR +0, DEX +3, CON -1, INT +2, WIS +2, CHA +0
|
// Elf: STR +0, DEX +3, CON -1, INT +2, WIS +3, CHA +0
|
||||||
base := [6]int{10, 10, 10, 10, 10, 10}
|
base := [6]int{10, 10, 10, 10, 10, 10}
|
||||||
got := applyRaceMods(RaceElf, base)
|
got := applyRaceMods(RaceElf, base)
|
||||||
want := [6]int{10, 13, 9, 12, 12, 10}
|
want := [6]int{10, 13, 9, 12, 13, 10}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("applyRaceMods(Elf) = %v, want %v", got, want)
|
t.Errorf("applyRaceMods(Elf) = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
// Orc: STR +5, DEX -1, CON +4, INT -1, WIS -1, CHA +0
|
// Orc: STR +6, DEX -1, CON +3, INT -1, WIS -1, CHA +0
|
||||||
got = applyRaceMods(RaceOrc, base)
|
got = applyRaceMods(RaceOrc, base)
|
||||||
want = [6]int{15, 9, 14, 9, 9, 10}
|
want = [6]int{16, 9, 13, 9, 9, 10}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("applyRaceMods(Orc) = %v, want %v", got, want)
|
t.Errorf("applyRaceMods(Orc) = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRaceBalance runs the weighted balance pass and logs the report.
|
||||||
|
// Standard Human baseline is 6.0 under every class; a race's best-fit
|
||||||
|
// score is its realistic effective-power ceiling. The assertion is a
|
||||||
|
// generous guard rail — see classStatWeights for the model.
|
||||||
|
func TestRaceBalance(t *testing.T) {
|
||||||
|
report := computeRaceBalance()
|
||||||
|
t.Logf("weighted race-balance pass (Human baseline = %.1f)", raceBalanceBaseline)
|
||||||
|
t.Logf("%-10s %-9s %6s %-9s %6s %6s %+6s",
|
||||||
|
"race", "best-fit", "score", "worst-fit", "score", "avg", "Δ")
|
||||||
|
for _, rb := range report {
|
||||||
|
t.Logf("%-10s %-9s %6.2f %-9s %6.2f %6.2f %+6.2f",
|
||||||
|
rb.Race, rb.BestClass, rb.BestScore,
|
||||||
|
rb.WorstClass, rb.WorstScore, rb.AvgScore, rb.Delta())
|
||||||
|
}
|
||||||
|
// Balance rule: equal *average* power. Every race's mean score across
|
||||||
|
// all playable classes must land within tolerance of the Human
|
||||||
|
// baseline. Best-fit/worst-fit spread is intentional race identity —
|
||||||
|
// a spiky race trades a higher ceiling for a lower floor — so only
|
||||||
|
// the average is asserted.
|
||||||
|
const tolerance = 0.5
|
||||||
|
for _, rb := range report {
|
||||||
|
if d := rb.AvgScore - raceBalanceBaseline; d < -tolerance || d > tolerance {
|
||||||
|
t.Errorf("%s avg %.2f is %.2f off the %.1f baseline (tolerance %.1f)",
|
||||||
|
rb.Race, rb.AvgScore, d, raceBalanceBaseline, tolerance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRaceClass(t *testing.T) {
|
func TestParseRaceClass(t *testing.T) {
|
||||||
if r, ok := parseRace("Elf"); !ok || r != RaceElf {
|
if r, ok := parseRace("Elf"); !ok || r != RaceElf {
|
||||||
t.Errorf("parseRace(Elf) = %v, %v", r, ok)
|
t.Errorf("parseRace(Elf) = %v, %v", r, ok)
|
||||||
|
|||||||
Reference in New Issue
Block a user