diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index d4abc0f..d4eaf0d 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -65,9 +65,19 @@ func main() { companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.") jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()") + + seed = flag.Int64("seed", -1, "single-run mode — deterministic seed for zone layout + run id + combat sessions (peripheral procs stay random). <0 = off (default). Matrix mode passes this to each subprocess automatically; use -base-seed there.") + baseSeed = flag.Int64("base-seed", -1, "matrix mode — deterministic base seed. Each cell's subprocess gets seed=mix(base,level,zone,rep) (class-independent, so every class faces identical dungeons + dice). <0 = off (default, time-seeded).") ) flag.Parse() + // Deterministic seeding for reproducible A/B tuning. Off unless -seed >= 0 + // (the matrix parent sets it per-subprocess from -base-seed). Prod never + // calls SeedSim, so this is inert outside the sim. + if *seed >= 0 { + plugin.SeedSim(*seed) + } + if *petLevel < 0 || *petLevel > 10 { fail("pet-level must be 0-10, got", *petLevel) } @@ -89,7 +99,7 @@ func main() { includeLog = *logFlag } }) - runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion) + runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion, *baseSeed) return } @@ -200,7 +210,18 @@ type matrixJob struct { rep int } -func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) { +// mixSeed derives a per-cell subprocess seed from the base seed and the cell's +// (level, zone, rep) — deliberately class-independent, so every class runs the +// identical dungeon + combat dice at a given cell and A/B class deltas pair. +func mixSeed(base int64, level int, zone string, rep int) int64 { + h := uint64(1469598103934665603) // FNV-1a offset basis + for _, c := range fmt.Sprintf("%d|%s|%d", level, zone, rep) { + h = (h ^ uint64(c)) * 1099511628211 + } + return int64((uint64(base) ^ h) &^ (uint64(1) << 63)) // non-negative +} + +func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) { cs := splitNonEmpty(classes) ls := parseLevels(levels) zs := splitNonEmpty(zones) @@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days var wg sync.WaitGroup for i := 0; i < jobs; i++ { wg.Add(1) - go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion) + go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion, baseSeed) } go func() { for _, j := range work { @@ -250,7 +271,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days } } -func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) { +func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) { defer wg.Done() for j := range in { uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) @@ -272,6 +293,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, fmt.Sprintf("-pet-level=%d", petLevel), fmt.Sprintf("-party=%d", party), } + if baseSeed >= 0 { + args = append(args, "-seed", strconv.FormatInt(mixSeed(baseSeed, j.level, j.zone, j.rep), 10)) + } // Left empty, each cell's followers clone that cell's own -class. if partyClasses != "" { args = append(args, "-party-classes", partyClasses) diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index c97376d..b5e3c62 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -371,7 +371,7 @@ type DeathTransitionResult struct { func transitionDeath(p DeathTransitionParams) DeathTransitionResult { var r DeathTransitionResult - if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 { + if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 { r.Pardoned = true now := time.Now().UTC() p.Char.LastPardonUsed = &now diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index e8d3f91..962a88a 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin PlayerHeal: out.PlayerHeal, EnemySkip: out.EnemySkip, } + // Revive the arcane-blaster sustained cantrip floor in the turn engine. + // combat_engine.go:590 deals CantripPerRound flat every round, but that + // path is the swing-based engine (SimulateCombat). Auto-resolve — the + // live path for every expedition — runs the turn engine, where a caster + // autocasts and never weapon-swings, so the floor was dead code: casters + // fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip + // damage to the floor, but only when the cast already connected + // (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35% + // miss variance survives and the floor doesn't become a guaranteed flat + // hammer (that overshot to ~99%). Damage cantrips only; slot spells keep + // their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero + // CantripPerRound, so this is self-targeting. + if spell.Level == 0 && eff.EnemyDamage > 0 && + (spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) { + if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage { + eff.EnemyDamage = floor + } + } // §1 — redirect the heal onto the named ally. The roll is the same; only // the body it lands on changes. This is the line that makes a cleric a // cleric: until it existed, every heal in the engine was a self-heal, and diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index b883c8d..0e2f5bc 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -482,6 +482,9 @@ const combatSessionCols = ` // newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions. func newCombatSessionID() string { + if simSeedOn() { + return simHexToken() + } var b [8]byte if _, err := cryptorand.Read(b[:]); err != nil { // Vanishingly unlikely; fall through with a zeroed prefix. diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index a6ad64b..db6d336 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -23,7 +23,6 @@ package plugin import ( "encoding/json" "fmt" - "math/rand/v2" "strings" "gogobee/internal/flavor" @@ -75,7 +74,7 @@ func resolveCombatInterrupt( rollFn func() int, ) (CombatInterruptKind, int) { if rollFn == nil { - rollFn = func() int { return rand.IntN(20) + 1 } + rollFn = func() int { return simIntN(20) + 1 } } r := rollFn() mod := tier @@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int { if tier < 1 { tier = 1 } - dmg := 1 + rand.IntN(4) + m.AttackBonus/2 + dmg := 1 + simIntN(4) + m.AttackBonus/2 floor := tier if floorOverride >= 0 { floor = floorOverride @@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter( return } chance := rollPatrolChance(exp.ThreatLevel) - if chance <= 0 || rand.Float64() > chance { + if chance <= 0 || simFloat64() > chance { return } monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false) diff --git a/internal/plugin/dnd_passives.go b/internal/plugin/dnd_passives.go index 624b3ec..780df00 100644 --- a/internal/plugin/dnd_passives.go +++ b/internal/plugin/dnd_passives.go @@ -112,6 +112,55 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte // AutoCritFirst is already a one-shot bool. // - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13. // The passive heal triggers first since both use the same threshold. +// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch +// Blast): 1 die L1–4, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane +// blaster damage (CantripPerRound) that models a caster's sustained at-will +// floor — see CombatModifiers.CantripPerRound. +func cantripDice(level int) int { + switch { + case level >= 17: + return 4 + case level >= 11: + return 3 + case level >= 5: + return 2 + default: + return 1 + } +} + +// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their +// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20 +// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage +// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test) +// correctly rejected. +// +// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so +// the primary lever is damage. Multiplicative form — the spell modifier +// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e +// Agonizing Blast. cantripDice scales 1→4 across levels. +// - Survival: just enough HP/Def (scaled by level) that the caster lives long +// enough for the cantrip to connect the kill — NOT a tank rider. calcDamage +// defense has diminishing returns, so this leans on HP. +// +// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the +// rebaseline plan for the sweep that set them. The cantrip floor only becomes a +// live lever once combat_cmd.go bridges CantripPerRound into the turn engine +// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage +// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip +// plus its structural edge already lands it mid-band, so an added floor would +// overshoot the 45 ceiling. +const ( + casterCantripBase = 3 // per-die base before the ability modifier rides in + casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1 +) + +func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) { + mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod)) + mods.CantripDesc = cantrip + stats.Defense += casterDefPerLevel * level +} + func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) { switch c.Class { case ClassFighter: @@ -127,18 +176,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // re-tune in a follow-up if their win curves drift after this. switch { case c.Level >= 20: - mods.ExtraAttacks += 3 + mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll) case c.Level >= 11: mods.ExtraAttacks += 2 case c.Level >= 5: mods.ExtraAttacks += 1 } + stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band case ClassRogue: mods.AutoCritFirst = true + if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5 + mods.ExtraAttacks += 1 + } + stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke // Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale // at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a // modest steady-DPS rider so post-opener rounds aren't pure attrition. - mods.DamageBonus += 0.05 + mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band // Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6 // per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20). // AutoCritFirst + DamageBonus alone left the rogue's defining @@ -154,6 +208,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact mods.SneakAttackDie += sneakDice case ClassMage: stats.AttackBonus++ + // At-will Fire Bolt + level-scaled survival — the sustained arcane floor. + casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt") // Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage // per round. A modest damage rider lifts weapon hits (DamageBonus does // not multiply queued SpellPreDamage — that path is its own field). @@ -194,6 +250,11 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // subclass DamageReduct riders. DamageReduct is initialized to 1.0 // by DerivePlayerStats before passives run. mods.DamageReduct *= 0.95 + // rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to + // every damage lever. DamageReduct is a defense multiplier (calcDamage) — + // <1 = take MORE damage. Combined with a damage trim to pull it to band. + mods.DamageReduct *= 0.2 + mods.DamageBonus += -0.20 // Phase 3 class-balance: druid was the only caster chassis with a // purely defensive passive, and the off-tier numbers showed it — // L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so @@ -219,6 +280,7 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact stats.AttackBonus++ mods.DamageBonus += 0.05 mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) + mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band case ClassSorcerer: // Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's // spellcasting stat. Floors at the flat base for low-CHA builds. @@ -231,6 +293,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // touching the +0.05 rider that already saturates at high tier. mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA)) mods.DamageBonus += 0.05 + stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit + // At-will Fire Bolt + level-scaled survival — sustained arcane floor. + casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt") case ClassWarlock: // Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack — // the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch @@ -240,6 +305,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact mods.DamageBonus += 0.12 stats.AttackBonus++ mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) + // At-will Eldritch Blast + level-scaled survival — sustained arcane floor. + casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast") case ClassPaladin: // Class-identity audit (2026-05-16) — Divine Smite as actual // per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a @@ -249,8 +316,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact // down so an extra-attack paladin doesn't trivialize every fight. // Rides DivineStrikePerHit (already in the weapon-hit damage path). // Previous FlatDmgStart opener felt like Lay on Hands, not Smite. - smite := 3 + c.Level/3 + smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band mods.DivineStrikePerHit += smite + mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing if c.Level >= 5 { mods.ExtraAttacks += 1 } diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 3163cf6..fba6900 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -2,7 +2,6 @@ package plugin import ( "fmt" - "math/rand/v2" "sort" "strings" "time" @@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { before := c.HPCurrent if !hpFull { conMod := abilityModifier(c.CON) - healDie := 1 + rand.IntN(6) // 1d6 + healDie := 1 + simIntN(6) // 1d6 heal := healDie + conMod if heal < 1 { heal = 1 diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index dc3096b..37178ca 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -3,7 +3,6 @@ package plugin import ( "fmt" "log/slog" - "math/rand/v2" "maunium.net/go/mautrix/id" ) @@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev // applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc. // Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage. func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) { - roll := 1 + rand.IntN(20) + roll := 1 + simIntN(20) isCrit := roll == 20 isFumble := roll == 1 if isFumble || (!isCrit && roll+atk < enemy.AC) { @@ -158,7 +157,7 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier // future multi-enemy combat (Phase 11+) but is not consulted here. func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) { saveMod := enemySpellSaveMod(enemy) - saveRoll := 1 + rand.IntN(20) + saveRoll := 1 + simIntN(20) saved := saveRoll+saveMod >= dc dmg := rollSpellDamageDice(spell, slot, c.Level) if saved { @@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch } total := 0 for i := 0; i < darts; i++ { - total += 1 + rand.IntN(4) + 1 + total += 1 + simIntN(4) + 1 } mods.SpellPreDamage += total mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total) @@ -223,7 +222,7 @@ func enemySpellSaveMod(enemy *CombatStats) int { // double damage (5e: paralyzed creatures auto-crit on melee hits). func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) { saveMod := enemySpellSaveMod(enemy) - saveRoll := 1 + rand.IntN(20) + saveRoll := 1 + simIntN(20) if saveRoll+saveMod >= dc { mods.SpellPreDamageDesc = spell.Name + " — resisted" return @@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in if supreme { heal += faces } else { - heal += 1 + rand.IntN(faces) + heal += 1 + simIntN(faces) } } heal += abilityModifier(c.WIS) @@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int { } total := flat for i := 0; i < dice; i++ { - total += 1 + rand.IntN(faces) + total += 1 + simIntN(faces) } if total < 1 { total = 1 diff --git a/internal/plugin/dnd_xp_test.go b/internal/plugin/dnd_xp_test.go index 44cd4b8..20b8bf1 100644 --- a/internal/plugin/dnd_xp_test.go +++ b/internal/plugin/dnd_xp_test.go @@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) { wantFlatStart int wantInitBias float64 }{ - {ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0}, + // Fighter-ceiling rebaseline (2026-07-16): Fighter picked up a -2 to-hit + // sub-swing trim; Rogue's steady rider flipped +0.05→-0.10 and it took a + // -3 to-hit trim (both offset a new 2nd swing gated at L5, not seen here); + // Druid took a survival trim (DR 0.95→0.19) + -0.20 damage; Bard a DR 0.4 + // survival trim; Sorcerer a +1 to-hit to match the blasters; Paladin a + // 0.9 DR survival trim. Caster CantripPerRound / MaxHP / Defense adds are + // not asserted here. + {ClassFighter, 0.05, -2, false, 0, 1.0, 0, 0}, // Phase 2 class-balance rebalance: rogue picked up +5% damage, // Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's // burst now also scales with level, and Warlock picked up +1 attack. @@ -233,7 +240,7 @@ func TestApplyClassPassives(t *testing.T) { // Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst // (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5 // (5 + 1 + clamp(mod(CHA=10)=0) = 6). - {ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0}, + {ClassRogue, -0.10, -3, true, 0, 1.0, 0, 0}, {ClassMage, 0.05, 1, false, 0, 1.0, 1, 0}, {ClassCleric, 0, 0, false, 5, 1.0, 0, 0}, // Class-identity audit (2026-05-16): Ranger Hunter's Mark is now @@ -243,11 +250,11 @@ func TestApplyClassPassives(t *testing.T) { // FlatDmgStart compensation riders are gone; +1 to-hit stays on // Ranger as the "read prey tells" half. {ClassRanger, 0, 1, false, 0, 1.0, 0, 0}, - {ClassDruid, 0, 0, false, 0, 0.95, 1, 0}, - {ClassBard, 0.05, 1, false, 0, 1.0, 1, 1}, - {ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0}, + {ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0}, + {ClassBard, 0.05, 1, false, 0, 0.4, 1, 1}, + {ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0}, {ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0}, - {ClassPaladin, 0, 0, false, 0, 1.0, 0, 0}, + {ClassPaladin, 0, 0, false, 0, 0.9, 0, 0}, } for _, tc := range cases { stats := CombatStats{AttackBonus: 5} diff --git a/internal/plugin/dnd_zone_loot.go b/internal/plugin/dnd_zone_loot.go index 25181d6..85e182b 100644 --- a/internal/plugin/dnd_zone_loot.go +++ b/internal/plugin/dnd_zone_loot.go @@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra // while production paths use the package-global generator. func rngFloat(rng *rand.Rand) float64 { if rng == nil { - return rand.Float64() + // Auto-resolve rooms pass nil. simFloat64 routes to the seeded combat + // stream when the sim is seeding, else the package global — so prod + // (never seeded) stays byte-identical while sim room combat pairs. + return simFloat64() } return rng.Float64() } @@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int { return 0 } if rng == nil { - return rand.IntN(n) + return simIntN(n) } return rng.IntN(n) } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index eb0d91b..706fb10 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType { // newRunID — 16-char hex token. Crypto-random; collision-resistant. func newRunID() string { + if simSeedOn() { + return simHexToken() + } var b [8]byte if _, err := cryptorand.Read(b[:]); err != nil { // Fall back to math/rand if /dev/urandom is unavailable. @@ -240,7 +243,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) } if rng == nil { - rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro()))) + if simSeedOn() { + rng = simZoneRNG() + } else { + rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro()))) + } } seq := generateRoomSequence(zone, rng) diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index cf7dba7..ea8c3b4 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -318,8 +318,16 @@ func applyClassBaselineStats(c *DnDCharacter) { c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10 case ClassRogue, ClassRanger: c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8 - case ClassMage, ClassSorcerer: + case ClassMage: c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10 + case ClassSorcerer: + // Sorcerer is a CHA caster (spellcastingMod → CHA), so its 16 goes in + // CHA, not INT. Previously it shared the Mage's INT-heavy array, which + // left the synthetic sorcerer casting at CHA mod 0 — every CHA-scaled + // ability (cantrip, Innate Sorcery, spell DCs) ran crippled and sorc + // trailed the field in every sweep. Prod players always placed 16 in + // CHA; this makes the sim's sorcerer match a real one. + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 10, 12, 16 case ClassCleric, ClassDruid: c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13 case ClassBard, ClassWarlock: diff --git a/internal/plugin/sim_seed.go b/internal/plugin/sim_seed.go new file mode 100644 index 0000000..03b924c --- /dev/null +++ b/internal/plugin/sim_seed.go @@ -0,0 +1,115 @@ +package plugin + +import ( + "encoding/hex" + "math/rand/v2" + "sync" + "sync/atomic" +) + +// Deterministic sim seeding — OFF by default, so the production binary is +// byte-identical in behaviour. The expedition-sim harness calls SeedSim once at +// subprocess startup to make a run reproducible: the three nondeterminism seams +// that dominate outcome variance — the zone-layout RNG, the run id (traps hash +// off it), and each combat SessionID (the whole turn engine hashes from it) — +// all derive from a single base seed via a process-local counter. +// +// This does NOT touch the peripheral top-level math/rand/v2 procs (pardon rolls, +// minor damage jitter, ambient events); those stay random. Seeding the three +// dominant seams collapses the batch-to-batch drift (identical dungeons + combat +// dice across arms) so an A/B passive change reads at ~0 noise. If a residual +// proc ever proves load-bearing, seed it too — but validate empirically first. +// +// Ordering contract: within one subprocess an expedition is resolved on a single +// goroutine, so nextSimSeed() is drawn in a deterministic order (zone rng, run +// id, then one per combat session in creation order). Two arms sharing a base +// seed draw identical values up to the point a passive change diverges them — +// and SessionIDs are assigned at session *creation*, before a fight resolves, so +// the Nth combat pairs regardless of how the fight plays out. +var ( + simSeedActive atomic.Bool + simSeedBase uint64 + simSeedCtr atomic.Uint64 +) + +// simCombatRand is an INDEPENDENT seeded stream for the outcome-decisive +// in-combat rolls that the three dominant seams don't cover: the spell +// attack/save/damage d20s (dnd_spell_combat.go), the 33% pardon death-cheat +// (combat_bridge.go), and the short-rest heal die (dnd_rest.go). These fire +// disproportionately in caster / borderline-boss runs — exactly the population +// being certified — and leaving them on the global generator was the main +// source of the per-cell residual (the fighter+ranger repro never exercised +// them, so it read ~0 noise while bard swung 8pp on identical code). +// +// It is a SEPARATE stream from nextSimSeed()'s counter so it never perturbs the +// zone-layout / run-id / SessionID draw order the martial repro validated. +// Within a subprocess an expedition runs on one goroutine; the mutex is belt- +// and-braces so a stray concurrent draw can't race, not a correctness crutch. +var ( + simCombatMu sync.Mutex + simCombatRand *rand.Rand +) + +// SeedSim activates deterministic seeding for this process. A negative seed +// disables it (the default). Call once at startup, before any expedition runs — +// it is not safe to toggle while a run is in flight. +func SeedSim(seed int64) { + if seed < 0 { + simSeedActive.Store(false) + simCombatRand = nil + return + } + simSeedBase = uint64(seed) + simSeedCtr.Store(0) + // 0x5EED5 gives the peripheral-combat stream a distinct sub-stream from the + // seam counter so the two never correlate or share draws. + simCombatRand = rand.New(rand.NewPCG(uint64(seed), 0x5EED5)) + simSeedActive.Store(true) +} + +func simSeedOn() bool { return simSeedActive.Load() } + +// nextSimSeed returns the next counter-mixed seed. Golden-ratio odd multiplier +// decorrelates successive draws. +func nextSimSeed() uint64 { + n := simSeedCtr.Add(1) + return simSeedBase ^ (n * 0x9E3779B97F4A7C15) +} + +// simHexToken renders one seeded draw as the same 16-char hex shape the +// crypto-random id helpers produce. +func simHexToken() string { + v := nextSimSeed() + var b [8]byte + for i := range b { + b[i] = byte(v >> (8 * i)) + } + return hex.EncodeToString(b[:]) +} + +// simZoneRNG returns a deterministic generator for one zone layout. +func simZoneRNG() *rand.Rand { + return rand.New(rand.NewPCG(nextSimSeed(), 0xC0FFEE)) +} + +// simIntN / simFloat64 draw an outcome-decisive in-combat roll from the seeded +// peripheral stream when seeding is active; otherwise they fall through to the +// global generator so the production binary stays byte-identical (prod never +// calls SeedSim, so simSeedOn() is always false there). +func simIntN(n int) int { + if !simSeedOn() { + return rand.IntN(n) + } + simCombatMu.Lock() + defer simCombatMu.Unlock() + return simCombatRand.IntN(n) +} + +func simFloat64() float64 { + if !simSeedOn() { + return rand.Float64() + } + simCombatMu.Lock() + defer simCombatMu.Unlock() + return simCombatRand.Float64() +}