mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
A. An armed ability lasted one round of a turn-based fight.
buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.
Split arming into its two halves:
consumeArmedAbility(c) mutates: disarms, saves, returns the id. Once,
at fight start.
applyAbilityByID(c, id, mods) pure: no DB write, no disarm. Safe on every
rebuild. (No ability's Apply writes to the
character, so this really is pure.)
armAbilityForFight(c, mods) consume + apply, for the auto-resolve callers
that build and fight in one breath.
buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.
The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.
Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.
B. Six unlocked read-modify-writes against the shared supply pool.
updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.
All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.
Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
529 lines
20 KiB
Go
529 lines
20 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, armAbilityForFight, 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)
|
||
// Concentration damage spells (heat_metal, spirit_guardians,
|
||
// flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …)
|
||
// re-tick each round while concentration holds. Without this factor
|
||
// the picker scores them as one-shots and they lose to higher-tier
|
||
// blasts on every comparison. Conservative ×3 = roughly the median
|
||
// fight length the picker can hope to keep concentration up; tier-
|
||
// scaled would be more correct but adds noise here.
|
||
// EffectDamageAttack is excluded — single-target attack-roll spells
|
||
// aren't generally concentration; the rare ones (hex-style) get
|
||
// their lift from mods, not this score.
|
||
if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) {
|
||
avg *= 3
|
||
}
|
||
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
|