From 9d6192dc6a8cf63222c5bc3c63ff5740e955904b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 19:47:01 -0700 Subject: [PATCH] =?UTF-8?q?D&D:=20weighted=20race-balance=20pass=20?= =?UTF-8?q?=E2=80=94=20tune=20races=20to=20equal=20effective=20power?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/plugin/dnd.go | 16 ++- internal/plugin/dnd_race_balance.go | 197 ++++++++++++++++++++++++++++ internal/plugin/dnd_test.go | 36 ++++- 3 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 internal/plugin/dnd_race_balance.go diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index eebd536..19c4041 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -71,13 +71,21 @@ type DnDClassInfo struct { 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{ {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"}, - {RaceDwarf, "Dwarf", [6]int{2, -1, 3, 1, 2, -1}, "Poison resistance; bonus vs. underground enemies"}, + {RaceElf, "Elf", [6]int{0, 3, -1, 2, 3, 0}, "Darkvision; immune to sleep effects"}, + {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"}, - {RaceOrc, "Orc", [6]int{5, -1, 4, -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"}, + {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, 1, 0, 3}, "Fire resistance; bonus on CHA checks"}, {RaceHalfElf, "Half-Elf", [6]int{0, 2, 0, 1, 1, 2}, "Two bonus skill proficiencies"}, } diff --git a/internal/plugin/dnd_race_balance.go b/internal/plugin/dnd_race_balance.go new file mode 100644 index 0000000..6f2848b --- /dev/null +++ b/internal/plugin/dnd_race_balance.go @@ -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 +} diff --git a/internal/plugin/dnd_test.go b/internal/plugin/dnd_test.go index 707e38f..f40aadc 100644 --- a/internal/plugin/dnd_test.go +++ b/internal/plugin/dnd_test.go @@ -106,21 +106,49 @@ func TestComputeAC(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} 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 { 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) - want = [6]int{15, 9, 14, 9, 9, 10} + want = [6]int{16, 9, 13, 9, 9, 10} if 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) { if r, ok := parseRace("Elf"); !ok || r != RaceElf { t.Errorf("parseRace(Elf) = %v, %v", r, ok)