mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Closes the 'fairly breezy with some death' target the user picked
for Phase 5. Five-piece ship; Phase 1 matrix lands T1 88%, T2 74%,
T4 72%, T5 ~57% in or above band. T3 remains the design hump at
~45% (manor 39, underforge 47) — Wraith promotion to elite was
already done in Phase 4-B, the remaining standard-pool deaths are
the irreducible part of T3.
Pieces:
1. computeMaxHP × 1.5 (phase5BHPMult in dnd.go). Uniform across
class/level so the class-balance harness's in-tier parity
assertion stays green. Bootstrap (bootstrap_phase5b_hp.go)
refreshes hp_max for existing characters at startup;
idempotent via db.JobCompleted. hp_current is bumped by the
same delta so a full-HP character stays at full.
2. applyPhase5BPlayerFloor (dnd_combat.go): +3 AC, +3 AttackBonus,
+3 weapon.MagicBonus (damage). Applied at the END of
applyDnDEquipmentLayer (after computeArmorAC's AC override)
and inside buildHarnessPlayer so live and harness measurement
match.
3. Elite bracket 19 → 23 (resolveCombatInterrupt). Case order
puts Elite (≥23) before Patrol (≥22) so a 23+ total prefers
the single dangerous fight. Elite is now effectively a
high-threat event reachable only via the +1-per-20-threat-
above-40 mod — Phase 4-B's elite-pool monsters still appear,
just less often.
4. dailyThreatDrift base 3 → 1. Slows the threat clock so
players have the days they need before threat tips zones
into the new 23+ elite band.
5. applyDailyBurn default → 50% (phase5BDailyBurnRatePct). Also
applied in the temporal-override branch in
dnd_expedition_cycle.go so tidal / unraveling days scale by
the same 0.5× — otherwise those days would be
disproportionately harsh against the new baseline.
The harness's expedition_balance.go reads phase5BDailyBurnRatePct
as the default-burn fallback when the override knob is zero, so
Phase 1 matrix measurements now reflect what live players
experience.
Test debt: 13 pinned-numbers unit tests across combat_stats_test,
dnd_test, dnd_xp_test, dnd_equipment_profiles_test,
dnd_expedition_supplies_test, dnd_expedition_cycle_test,
dnd_expedition_extract_test, dnd_expedition_region_cmd_test,
dnd_expedition_combat_test, dnd_expedition_threat_test,
dnd_expedition_temporal_test, expedition_balance_test were
pinning pre-Phase-5-B baselines; updated with comments noting
the cause. Class-balance suite stayed green (uniform buff
preserves spread).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
519 lines
19 KiB
Go
519 lines
19 KiB
Go
package plugin
|
||
|
||
import (
|
||
"math/rand/v2"
|
||
"sort"
|
||
)
|
||
|
||
// Measurement harness for the class-balance pass (gogobee_class_balance.md).
|
||
// Phase 0 introduced this for a Fighter-vs-Mage spike; Phase 1 extended it
|
||
// to drive the full 10-class × 30-subclass matrix (subclass=="" at L1–L4,
|
||
// each of a class's three subclasses at the L5/L7/L10/L15/L20 checkpoints).
|
||
//
|
||
// Sibling to dnd_race_balance.go — same spirit, different method. Races
|
||
// don't fight, so race balance had to use a hand-weighted scoring proxy.
|
||
// Classes do fight: combat collapses to a single seedable call into the
|
||
// engine, so class balance is *measured*, not modeled. This file is the
|
||
// measurement harness.
|
||
//
|
||
// Scope here is Phase 0 only: build a synthetic Fighter and Mage at a
|
||
// handful of levels, layer equipment and a queued spell the same way live
|
||
// combat does, run N fights per dungeon tier, read the win rates. The
|
||
// goal is to sanity-check the two policies the doc flags in §3 — the
|
||
// equipment loadout and the spell-selection heuristic — before Phase 1
|
||
// generalizes to all 10 classes × 30 subclasses.
|
||
//
|
||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||
//
|
||
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||
// Go; tests run without a sqlite instance.
|
||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||
// - Inventory consumables: empty.
|
||
//
|
||
// Everything else flows through the production code paths
|
||
// (applyDnDPlayerLayer, applyClassPassives, applyRacePassives, the
|
||
// equipment-derived AC/weapon-dice resolution inside SimulateCombat) so
|
||
// numbers from this harness are directly comparable to live fights at
|
||
// the same character level.
|
||
|
||
// ── Build profile ────────────────────────────────────────────────────────────
|
||
|
||
// classBalanceProfile is one row of the matrix: a single class build at a
|
||
// single level. Race is fixed to Human (the +1-to-all neutral baseline
|
||
// shipped in the race-balance pass) so class numbers aren't skewed by
|
||
// racial mods.
|
||
type classBalanceProfile struct {
|
||
Class DnDClass
|
||
Subclass DnDSubclass // empty below L5, per doc §2
|
||
Level int
|
||
}
|
||
|
||
// classBalanceResult is the empirical performance of one profile against
|
||
// one dungeon tier. WinRate is the headline number; the rest are
|
||
// diagnostics logged but not asserted on (per doc §4).
|
||
type classBalanceResult struct {
|
||
Profile classBalanceProfile
|
||
Tier int
|
||
Trials int
|
||
Wins int
|
||
AvgHPRemainingPct float64 // mean of endHP/MaxHP across won trials; 0 if no wins
|
||
NearDeathRate float64 // fraction of trials flagged NearDeath
|
||
}
|
||
|
||
// WinRate is the cell value the doc's parity rule asserts on once we get
|
||
// to Phase 2 tuning.
|
||
func (r classBalanceResult) WinRate() float64 {
|
||
if r.Trials == 0 {
|
||
return 0
|
||
}
|
||
return float64(r.Wins) / float64(r.Trials)
|
||
}
|
||
|
||
// ── Equipment loadout policy (doc §3.1) ──────────────────────────────────────
|
||
//
|
||
// One of the two policies Phase 0 exists to de-risk. The kit must be
|
||
// standardized fairly across classes — otherwise downstream win rates
|
||
// reflect the kit, not the class. The mapping below treats character
|
||
// level as a proxy for "what tier of gear would a player at this level
|
||
// realistically be holding," using the dungeon tier MinLevel ladder
|
||
// (advDungeons in adventure_activities.go) as the reference.
|
||
//
|
||
// L1–L4 → T1 mundane kit (no magic bonus)
|
||
// L5–L8 → T2, +1 weapon and armor
|
||
// L9–L12 → T3, +2
|
||
// L13–L16 → T4, +3
|
||
// L17–L20 → T5, +3 (cap — the appendix doesn't go higher)
|
||
//
|
||
// Per-class kit choice tracks the character's primary attack stat: STR
|
||
// martials wear heavy armor and swing martial-melee; DEX skirmishers
|
||
// take light armor and a finesse weapon; casters keep a quarterstaff
|
||
// and rely on Mage Armor / class AC floors instead of armor proficiency.
|
||
|
||
// gearTier maps a character level to a 1..5 magic/quality tier for the
|
||
// loadout policy. Kept private and tunable in one place.
|
||
func gearTier(level int) int {
|
||
switch {
|
||
case level >= 17:
|
||
return 5
|
||
case level >= 13:
|
||
return 4
|
||
case level >= 9:
|
||
return 3
|
||
case level >= 5:
|
||
return 2
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// magicBonusForTier is the +X enchantment we hand the player at this tier.
|
||
// T1 is mundane; the +1/+2/+3 ladder mirrors gogobee_equipment_appendix.md
|
||
// §7 magic-weapon tiers.
|
||
func magicBonusForTier(tier int) int {
|
||
switch tier {
|
||
case 1:
|
||
return 0
|
||
case 2:
|
||
return 1
|
||
case 3:
|
||
return 2
|
||
default:
|
||
return 3
|
||
}
|
||
}
|
||
|
||
// classLoadout is the standardized weapon + armor + shield kit a class
|
||
// fights with at this level. Returning copies so the caller can mutate
|
||
// MagicBonus without poisoning the registry. armor or shield may be nil.
|
||
func classLoadout(class DnDClass, level int) (weapon *WeaponProfile, armor *ArmorProfile, shield *ArmorProfile) {
|
||
tier := gearTier(level)
|
||
mb := magicBonusForTier(tier)
|
||
|
||
weaponID, armorID, useShield := classLoadoutIDs(class)
|
||
if w := weaponByID(weaponID); w != nil {
|
||
copy := *w
|
||
copy.MagicBonus = mb
|
||
weapon = ©
|
||
}
|
||
if armorID != "" {
|
||
if a := armorByID(armorID); a != nil {
|
||
copy := *a
|
||
copy.MagicBonus = mb
|
||
armor = ©
|
||
}
|
||
}
|
||
if useShield {
|
||
if s := armorByID("arm_shield"); s != nil {
|
||
copy := *s
|
||
// Shields don't get the weapon-tier enchantment in this kit;
|
||
// keeping them mundane avoids double-counting the +X.
|
||
shield = ©
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// classLoadoutIDs picks the canonical weapon / armor / shield set per
|
||
// class. Choices follow the class's PrimaryA stat and 5e proficiency
|
||
// expectations — Fighter swings martial melee in heavy armor; Mage stays
|
||
// behind a quarterstaff and lets Mage Armor / DEX carry AC.
|
||
func classLoadoutIDs(class DnDClass) (weapon, armor string, shield bool) {
|
||
switch class {
|
||
case ClassFighter, ClassPaladin:
|
||
return "wpn_longsword", "arm_chain_mail", true
|
||
case ClassRanger:
|
||
// DEX skirmisher with a finesse-friendly bow; light armor, no
|
||
// shield (two-handed bow occupies the off-hand anyway).
|
||
return "wpn_longbow", "arm_studded", false
|
||
case ClassRogue:
|
||
return "wpn_shortsword", "arm_studded", false
|
||
case ClassCleric:
|
||
return "wpn_mace", "arm_chain_shirt", true
|
||
case ClassDruid:
|
||
// Druids canonically eschew metal armor; hide is the SRD default.
|
||
return "wpn_scimitar", "arm_hide", false
|
||
case ClassBard:
|
||
return "wpn_rapier", "arm_leather", false
|
||
case ClassMage, ClassSorcerer, ClassWarlock:
|
||
// No armor proficiency. The Mage's class AC floor + DEX + a future
|
||
// queued Mage Armor cast carry survival. Quarterstaff is the
|
||
// canonical caster sidearm.
|
||
return "wpn_quarterstaff", "", false
|
||
}
|
||
return "wpn_club", "", false
|
||
}
|
||
|
||
// ── Spell-selection policy (doc §3.2) ────────────────────────────────────────
|
||
//
|
||
// The other Phase 0 policy. Without a "what would a caster cast here"
|
||
// heuristic, the 8 caster classes fight as naked weapon-users — a
|
||
// measurement artifact, not real imbalance.
|
||
//
|
||
// Phase 0 simplification: pick the single best damage spell from the
|
||
// class's available spells (level ≤ highest slot the build owns). "Best"
|
||
// is the expected damage of one cast under generous assumptions — avg
|
||
// dice × cantrip/upcast scaling — ignoring hit chance and save-half
|
||
// (those would require knowing the target's AC/save, which we don't have
|
||
// at selection time). This gets the Mage casting Magic Missile / Fireball
|
||
// instead of Fire Bolt's weaker auto-damage, which is the whole point.
|
||
//
|
||
// Phase 1 will refine this: per-fight slot bookkeeping for multi-round
|
||
// fights, fight-context selection (control vs. damage), buff pre-casts.
|
||
|
||
// pickBestDamageSpell returns the spell a caster of this class+level
|
||
// would queue for one fight, plus the slot level to upcast at. Returns
|
||
// (zero, 0, false) for non-casters and classes with no damage spells.
|
||
func pickBestDamageSpell(c *DnDCharacter) (SpellDefinition, int, bool) {
|
||
if !classIsCaster(c.Class) {
|
||
return SpellDefinition{}, 0, false
|
||
}
|
||
slots := slotsForClassLevel(c.Class, c.Level)
|
||
maxSlot := 0
|
||
for lvl := range slots {
|
||
if slots[lvl] > 0 && lvl > maxSlot {
|
||
maxSlot = lvl
|
||
}
|
||
}
|
||
candidates := spellsForClass(c.Class, maxSlot)
|
||
var best SpellDefinition
|
||
var bestSlot int
|
||
bestScore := -1.0
|
||
for _, s := range candidates {
|
||
switch s.Effect {
|
||
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||
default:
|
||
continue
|
||
}
|
||
// Cantrip → always castable, no slot cost.
|
||
// Leveled → upcast to maxSlot when we own a slot ≥ spell level.
|
||
slot := s.Level
|
||
if s.Level == 0 {
|
||
slot = 0
|
||
} else if slots[s.Level] == 0 && s.Level > 0 {
|
||
continue
|
||
} else if maxSlot > s.Level {
|
||
slot = maxSlot
|
||
}
|
||
score := spellExpectedDamage(s, slot, c.Level)
|
||
if score > bestScore {
|
||
bestScore = score
|
||
best = s
|
||
bestSlot = slot
|
||
}
|
||
}
|
||
if bestScore < 0 {
|
||
return SpellDefinition{}, 0, false
|
||
}
|
||
return best, bestSlot, true
|
||
}
|
||
|
||
// spellExpectedDamage estimates the average raw damage of one cast — dice
|
||
// count × avg-face + flat, with cantrip/upcast scaling identical to
|
||
// rollSpellDamageDice. No hit-chance or save-half weighting (see policy
|
||
// note above). Magic Missile's auto-damage path gets a small explicit
|
||
// bonus to reflect that it never misses; the auto-damage flag alone
|
||
// already steers picks correctly in practice.
|
||
func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
|
||
dice, faces, flat := parseDamageDice(s.DamageDice)
|
||
if dice == 0 || faces == 0 {
|
||
return 0
|
||
}
|
||
if s.Level == 0 {
|
||
switch {
|
||
case charLevel >= 17:
|
||
dice *= 4
|
||
case charLevel >= 11:
|
||
dice *= 3
|
||
case charLevel >= 5:
|
||
dice *= 2
|
||
}
|
||
} else if extra := slot - s.Level; extra > 0 {
|
||
dice += extra
|
||
}
|
||
avgFace := (float64(faces) + 1) / 2
|
||
avg := float64(dice)*avgFace + float64(flat)
|
||
// Auto-damage (Magic Missile) doesn't roll to hit — count its
|
||
// expected-on-table value at face. Attack/save spells roll, and the
|
||
// engine will resolve hit chance at cast time.
|
||
return avg
|
||
}
|
||
|
||
// applyHarnessSpellCast is the DB-free version of applyPendingCast: same
|
||
// damage resolution, no SaveDnDCharacter. Mirrors the live path's choice
|
||
// of attack/save/auto handlers so the Mage's contribution to a fight is
|
||
// the same shape as it would be in production.
|
||
func applyHarnessSpellCast(
|
||
c *DnDCharacter,
|
||
spell SpellDefinition,
|
||
slot int,
|
||
playerStats *CombatStats,
|
||
playerMods *CombatModifiers,
|
||
enemyStats *CombatStats,
|
||
) {
|
||
dc := spellSaveDC(c)
|
||
atk := spellAttackBonus(c)
|
||
preDmgBefore := playerMods.SpellPreDamage
|
||
switch spell.Effect {
|
||
case EffectDamageAttack:
|
||
applySpellDamageAttack(spell, atk, playerMods, enemyStats, slot, c.Level)
|
||
case EffectDamageSave:
|
||
applySpellDamageSave(spell, dc, c, playerMods, enemyStats, slot)
|
||
case EffectDamageAuto:
|
||
applySpellDamageAuto(spell, playerMods, slot, c.Level)
|
||
}
|
||
if playerMods.SpellPreDamage > preDmgBefore && c.Class == ClassMage {
|
||
// Mage evocation/necromancy hooks live in the live spell-combat
|
||
// path; we never set a subclass in Phase 0, so the call is a
|
||
// no-op today but keeps shape parity for Phase 1.
|
||
applyMageSubclassSpellHooks(c, spell, slot, playerMods)
|
||
}
|
||
}
|
||
|
||
// ── Synthesizing the build ───────────────────────────────────────────────────
|
||
|
||
// buildHarnessCharacter constructs the DnDCharacter for one profile. Uses
|
||
// the class's stat priority + Human's +1-to-all racial mods, then derives
|
||
// HP and the baseline AC from the class.
|
||
func buildHarnessCharacter(p classBalanceProfile) *DnDCharacter {
|
||
scores := classStatPriority(p.Class)
|
||
scores = applyRaceMods(RaceHuman, scores)
|
||
c := &DnDCharacter{
|
||
Race: RaceHuman,
|
||
Class: p.Class,
|
||
Subclass: p.Subclass,
|
||
Level: p.Level,
|
||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||
}
|
||
conMod := abilityModifier(c.CON)
|
||
dexMod := abilityModifier(c.DEX)
|
||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||
c.HPCurrent = c.HPMax
|
||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||
return c
|
||
}
|
||
|
||
// buildHarnessPlayer assembles the Combatant the engine will fight with.
|
||
// Layers the same calls runDungeonCombat makes, in order, minus the
|
||
// DB-touching ones (per the file header). Returns the Combatant; the
|
||
// caller decides whether to queue a spell on top.
|
||
func buildHarnessPlayer(c *DnDCharacter) Combatant {
|
||
stats := CombatStats{}
|
||
mods := CombatModifiers{DamageReduct: 1.0}
|
||
|
||
// 1. Player layer (HP/AC/AttackBonus from the sheet).
|
||
applyDnDPlayerLayer(&stats, c)
|
||
|
||
// 2. Equipment layer — inlined from applyDnDEquipmentLayer to avoid
|
||
// the AdvEquipment synthesis chain. Same net effect on stats.
|
||
weapon, armor, shield := classLoadout(c.Class, c.Level)
|
||
if weapon != nil {
|
||
stats.Weapon = weapon
|
||
stats.AbilityModForDamage = pickWeaponAbilityMod(weapon, c)
|
||
stats.WeaponProficient = dndClassWeaponProficiency(c.Class, weapon)
|
||
stats.AttackBonus += weapon.MagicBonus
|
||
if weapon.HasProperty(PropTwoHanded) || (weapon.HasProperty(PropVersatile) && shield == nil) {
|
||
stats.TwoHandedMode = true
|
||
}
|
||
}
|
||
// Two-handed weapons forbid shields (appendix §5.4).
|
||
if weapon != nil && weapon.HasProperty(PropTwoHanded) {
|
||
shield = nil
|
||
}
|
||
if armor != nil || shield != nil {
|
||
stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX))
|
||
}
|
||
|
||
// Phase 5-B player power floor. applyDnDEquipmentLayer applies this
|
||
// at the same point in the live combat path — keep the harness's
|
||
// measurement aligned with what live players experience by calling
|
||
// the same helper here, before passives stack on top.
|
||
applyPhase5BPlayerFloor(&stats)
|
||
|
||
// 3. Passives. Live order is class → race → subclass (see
|
||
// combat_bridge.go and combat_session_build.go). Subclass passives are
|
||
// a no-op when c.Subclass == "" — the harness uses that for the L1–L4
|
||
// pre-unlock rows.
|
||
applyClassPassives(&stats, &mods, c)
|
||
applyRacePassives(&stats, &mods, c)
|
||
applySubclassPassives(&stats, &mods, c)
|
||
|
||
return Combatant{
|
||
Name: string(c.Class),
|
||
Stats: stats,
|
||
Mods: mods,
|
||
IsPlayer: true,
|
||
}
|
||
}
|
||
|
||
// buildHarnessEnemy mirrors runDungeonCombat's enemy assembly: the
|
||
// per-tier stat curve from DeriveDungeonMonsterStats, then the d20
|
||
// AC/AttackBonus overlay from applyDnDDungeonMonsterLayer. No
|
||
// MonsterAbility — Phase 0 measures the base case.
|
||
func buildHarnessEnemy(tier int) Combatant {
|
||
loc := dungeonLocForTier(tier)
|
||
stats, mods := DeriveDungeonMonsterStats(loc)
|
||
applyDnDDungeonMonsterLayer(&stats, tier)
|
||
return Combatant{Name: loc.Denizens, Stats: stats, Mods: mods}
|
||
}
|
||
|
||
// dungeonLocForTier returns the canonical advDungeons row for a tier.
|
||
// Falls back to T1 for out-of-range input.
|
||
func dungeonLocForTier(tier int) *AdvLocation {
|
||
for i := range advDungeons {
|
||
if advDungeons[i].Tier == tier {
|
||
return &advDungeons[i]
|
||
}
|
||
}
|
||
return &advDungeons[0]
|
||
}
|
||
|
||
// ── Monte Carlo runner ───────────────────────────────────────────────────────
|
||
|
||
// runClassBalanceTrial runs one fight: build player + enemy fresh, queue
|
||
// the caster's best spell if applicable, simulate, return the result.
|
||
// Each trial constructs fresh combatants so the per-fight RNG (rand.IntN
|
||
// in spell rolls + the engine's package-global rand) drives variance.
|
||
func runClassBalanceTrial(p classBalanceProfile, tier int) CombatResult {
|
||
c := buildHarnessCharacter(p)
|
||
player := buildHarnessPlayer(c)
|
||
enemy := buildHarnessEnemy(tier)
|
||
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||
}
|
||
return SimulateCombat(player, enemy, dungeonCombatPhases)
|
||
}
|
||
|
||
// runClassBalanceCell is one cell of the matrix: N trials of (profile,
|
||
// tier). Returns aggregated win rate + diagnostics.
|
||
func runClassBalanceCell(p classBalanceProfile, tier, trials int) classBalanceResult {
|
||
r := classBalanceResult{Profile: p, Tier: tier, Trials: trials}
|
||
var hpSum float64
|
||
var hpWonTrials int
|
||
for i := 0; i < trials; i++ {
|
||
res := runClassBalanceTrial(p, tier)
|
||
if res.PlayerWon {
|
||
r.Wins++
|
||
if res.PlayerStartHP > 0 {
|
||
hpSum += float64(res.PlayerEndHP) / float64(res.PlayerStartHP)
|
||
hpWonTrials++
|
||
}
|
||
}
|
||
if res.NearDeath {
|
||
r.NearDeathRate++
|
||
}
|
||
}
|
||
if hpWonTrials > 0 {
|
||
r.AvgHPRemainingPct = hpSum / float64(hpWonTrials)
|
||
}
|
||
if trials > 0 {
|
||
r.NearDeathRate /= float64(trials)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// runClassBalanceMatrix sweeps a list of profiles across the full T1..T5
|
||
// dungeon ladder. Returns results sorted by (Tier asc, Class, Level) for
|
||
// deterministic test output.
|
||
func runClassBalanceMatrix(profiles []classBalanceProfile, trials int) []classBalanceResult {
|
||
tiers := []int{1, 2, 3, 4, 5}
|
||
out := make([]classBalanceResult, 0, len(profiles)*len(tiers))
|
||
for _, p := range profiles {
|
||
for _, t := range tiers {
|
||
out = append(out, runClassBalanceCell(p, t, trials))
|
||
}
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].Tier != out[j].Tier {
|
||
return out[i].Tier < out[j].Tier
|
||
}
|
||
if out[i].Profile.Class != out[j].Profile.Class {
|
||
return out[i].Profile.Class < out[j].Profile.Class
|
||
}
|
||
return out[i].Profile.Level < out[j].Profile.Level
|
||
})
|
||
return out
|
||
}
|
||
|
||
// ── Phase 1 matrix builder ───────────────────────────────────────────────────
|
||
|
||
// phase1SubclassLevels is the post-unlock checkpoint ladder from doc §2.
|
||
// L5/L7/L10/L15/L20 line up with the subclass tier-unlock structure in
|
||
// dnd_subclass_combat.go — each row reads a class's behaviour at one more
|
||
// unlocked tier than the row above it.
|
||
var phase1SubclassLevels = []int{5, 7, 10, 15, 20}
|
||
|
||
// phase1PreSubclassLevels is the L1–L4 ladder run with Subclass=="". Doc §2
|
||
// notes that subclasses aren't selected until L5, so these rows measure the
|
||
// raw class chassis.
|
||
var phase1PreSubclassLevels = []int{1, 2, 3, 4}
|
||
|
||
// buildPhase1Profiles assembles the full Phase 1 build matrix: every class
|
||
// at L1–L4 (no subclass), then each of that class's three subclasses at
|
||
// each of the five tier-unlock checkpoints. 10 × 4 + 10 × 3 × 5 = 190 rows.
|
||
// Order is registry order (dndClasses, then subclassesForClass) so the
|
||
// matrix log reads the same way as the design doc and the !class help.
|
||
func buildPhase1Profiles() []classBalanceProfile {
|
||
out := make([]classBalanceProfile, 0, 10*4+10*3*5)
|
||
for _, ci := range dndClasses {
|
||
for _, lvl := range phase1PreSubclassLevels {
|
||
out = append(out, classBalanceProfile{Class: ci.Key, Level: lvl})
|
||
}
|
||
for _, si := range subclassesForClass(ci.Key) {
|
||
for _, lvl := range phase1SubclassLevels {
|
||
out = append(out, classBalanceProfile{
|
||
Class: ci.Key,
|
||
Subclass: si.ID,
|
||
Level: lvl,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// _ keeps the math/rand/v2 import live in case future iterations of this
|
||
// file want to draw directly (e.g. for harness-level RNG control). Today
|
||
// every randomized step is inside production helpers.
|
||
var _ = rand.IntN
|