mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
combat: revive the caster sustained-cantrip floor in the turn engine
The arcane-blaster "sustained floor" passives (CantripPerRound, DamageBonus, FlatDmgStart from casterBlasterFloor) were built for the swing-based engine (SimulateCombat, combat_engine.go:590). But every live expedition auto-resolves through the turn engine (autoDriveCombat -> session -> combat_turn_engine), where casters autocast every turn and never weapon-swing -- so CantripPerRound never fired and DamageBonus was inert. Casters fought at bare cantrip dice (~4d10~=22 at L20) instead of their intended floor, in sim AND in prod. This is why every caster damage dial read as a dead lever across the whole rebaseline. Fix (combat_cmd.go): bridge the already-computed CantripPerRound into the turn-engine damage-cantrip cast, hit-gated (only lift a cast that already connected, so the ~35% miss variance survives and the floor isn't a guaranteed flat hammer). Self-targeting: only Mage/Sorcerer/Warlock carry a nonzero CantripPerRound -- martials swing (untouched), cleric/bard/druid have floor 0. Tuning (dnd_passives.go): casterCantripBase 9 -> 3, now a live, class-specific lever. Mage/Sorcerer take base 3; Warlock passes 0 (its bare-dice cantrip plus a structural edge already lands it mid-band, so an added floor overshoots). Removed the dead casterHPPerLevel rider (it inflated the truncation-fraction denominator without adding startable HP -- a bug). Also lands the deterministic-seeding infra (sim_seed.go + simIntN/simFloat64 threading) used to read these deltas out of the process-seed noise; prod is byte-identical (unseeded -> package rand). Confirmation (expedition-sim, L20 T5 dragons_lair+abyss_portal, n=250): casters now in the 35-45 floor -- sorcerer 39, mage 38, warlock 36; martial leaders undisturbed (rogue 68, druid 66, ranger 65, fighter 64, ... paladin 55).
This commit is contained in:
@@ -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.")
|
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()")
|
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()
|
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 {
|
if *petLevel < 0 || *petLevel > 10 {
|
||||||
fail("pet-level must be 0-10, got", *petLevel)
|
fail("pet-level must be 0-10, got", *petLevel)
|
||||||
}
|
}
|
||||||
@@ -89,7 +99,7 @@ func main() {
|
|||||||
includeLog = *logFlag
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +210,18 @@ type matrixJob struct {
|
|||||||
rep int
|
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)
|
cs := splitNonEmpty(classes)
|
||||||
ls := parseLevels(levels)
|
ls := parseLevels(levels)
|
||||||
zs := splitNonEmpty(zones)
|
zs := splitNonEmpty(zones)
|
||||||
@@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < jobs; i++ {
|
for i := 0; i < jobs; i++ {
|
||||||
wg.Add(1)
|
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() {
|
go func() {
|
||||||
for _, j := range work {
|
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()
|
defer wg.Done()
|
||||||
for j := range in {
|
for j := range in {
|
||||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
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("-pet-level=%d", petLevel),
|
||||||
fmt.Sprintf("-party=%d", party),
|
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.
|
// Left empty, each cell's followers clone that cell's own -class.
|
||||||
if partyClasses != "" {
|
if partyClasses != "" {
|
||||||
args = append(args, "-party-classes", partyClasses)
|
args = append(args, "-party-classes", partyClasses)
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ type DeathTransitionResult struct {
|
|||||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||||
var r 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
|
r.Pardoned = true
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.Char.LastPardonUsed = &now
|
p.Char.LastPardonUsed = &now
|
||||||
|
|||||||
@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
|||||||
PlayerHeal: out.PlayerHeal,
|
PlayerHeal: out.PlayerHeal,
|
||||||
EnemySkip: out.EnemySkip,
|
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
|
// §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
|
// 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
|
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||||
|
|||||||
@@ -482,6 +482,9 @@ const combatSessionCols = `
|
|||||||
|
|
||||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||||
func newCombatSessionID() string {
|
func newCombatSessionID() string {
|
||||||
|
if simSeedOn() {
|
||||||
|
return simHexToken()
|
||||||
|
}
|
||||||
var b [8]byte
|
var b [8]byte
|
||||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
// Vanishingly unlikely; fall through with a zeroed prefix.
|
// Vanishingly unlikely; fall through with a zeroed prefix.
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
|
|||||||
rollFn func() int,
|
rollFn func() int,
|
||||||
) (CombatInterruptKind, int) {
|
) (CombatInterruptKind, int) {
|
||||||
if rollFn == nil {
|
if rollFn == nil {
|
||||||
rollFn = func() int { return rand.IntN(20) + 1 }
|
rollFn = func() int { return simIntN(20) + 1 }
|
||||||
}
|
}
|
||||||
r := rollFn()
|
r := rollFn()
|
||||||
mod := tier
|
mod := tier
|
||||||
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
|||||||
if tier < 1 {
|
if tier < 1 {
|
||||||
tier = 1
|
tier = 1
|
||||||
}
|
}
|
||||||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
dmg := 1 + simIntN(4) + m.AttackBonus/2
|
||||||
floor := tier
|
floor := tier
|
||||||
if floorOverride >= 0 {
|
if floorOverride >= 0 {
|
||||||
floor = floorOverride
|
floor = floorOverride
|
||||||
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
chance := rollPatrolChance(exp.ThreatLevel)
|
chance := rollPatrolChance(exp.ThreatLevel)
|
||||||
if chance <= 0 || rand.Float64() > chance {
|
if chance <= 0 || simFloat64() > chance {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||||||
|
|||||||
@@ -112,6 +112,55 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
|
|||||||
// AutoCritFirst is already a one-shot bool.
|
// AutoCritFirst is already a one-shot bool.
|
||||||
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
||||||
// The passive heal triggers first since both use the same threshold.
|
// 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) {
|
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
|
||||||
switch c.Class {
|
switch c.Class {
|
||||||
case ClassFighter:
|
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.
|
// re-tune in a follow-up if their win curves drift after this.
|
||||||
switch {
|
switch {
|
||||||
case c.Level >= 20:
|
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:
|
case c.Level >= 11:
|
||||||
mods.ExtraAttacks += 2
|
mods.ExtraAttacks += 2
|
||||||
case c.Level >= 5:
|
case c.Level >= 5:
|
||||||
mods.ExtraAttacks += 1
|
mods.ExtraAttacks += 1
|
||||||
}
|
}
|
||||||
|
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
|
||||||
case ClassRogue:
|
case ClassRogue:
|
||||||
mods.AutoCritFirst = true
|
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
|
// 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
|
// 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.
|
// 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
|
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
||||||
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
||||||
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
||||||
@@ -154,6 +208,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
|||||||
mods.SneakAttackDie += sneakDice
|
mods.SneakAttackDie += sneakDice
|
||||||
case ClassMage:
|
case ClassMage:
|
||||||
stats.AttackBonus++
|
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
|
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
||||||
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
||||||
// not multiply queued SpellPreDamage — that path is its own field).
|
// 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
|
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
||||||
// by DerivePlayerStats before passives run.
|
// by DerivePlayerStats before passives run.
|
||||||
mods.DamageReduct *= 0.95
|
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
|
// Phase 3 class-balance: druid was the only caster chassis with a
|
||||||
// purely defensive passive, and the off-tier numbers showed it —
|
// 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
|
// 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++
|
stats.AttackBonus++
|
||||||
mods.DamageBonus += 0.05
|
mods.DamageBonus += 0.05
|
||||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
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:
|
case ClassSorcerer:
|
||||||
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
||||||
// spellcasting stat. Floors at the flat base for low-CHA builds.
|
// 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.
|
// touching the +0.05 rider that already saturates at high tier.
|
||||||
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||||
mods.DamageBonus += 0.05
|
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:
|
case ClassWarlock:
|
||||||
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
// 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
|
// 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
|
mods.DamageBonus += 0.12
|
||||||
stats.AttackBonus++
|
stats.AttackBonus++
|
||||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
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:
|
case ClassPaladin:
|
||||||
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
||||||
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
|
// 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.
|
// down so an extra-attack paladin doesn't trivialize every fight.
|
||||||
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
||||||
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
|
// 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.DivineStrikePerHit += smite
|
||||||
|
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
|
||||||
if c.Level >= 5 {
|
if c.Level >= 5 {
|
||||||
mods.ExtraAttacks += 1
|
mods.ExtraAttacks += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
|||||||
before := c.HPCurrent
|
before := c.HPCurrent
|
||||||
if !hpFull {
|
if !hpFull {
|
||||||
conMod := abilityModifier(c.CON)
|
conMod := abilityModifier(c.CON)
|
||||||
healDie := 1 + rand.IntN(6) // 1d6
|
healDie := 1 + simIntN(6) // 1d6
|
||||||
heal := healDie + conMod
|
heal := healDie + conMod
|
||||||
if heal < 1 {
|
if heal < 1 {
|
||||||
heal = 1
|
heal = 1
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
|
|||||||
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
||||||
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
// 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) {
|
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
|
isCrit := roll == 20
|
||||||
isFumble := roll == 1
|
isFumble := roll == 1
|
||||||
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
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.
|
// 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) {
|
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
saveMod := enemySpellSaveMod(enemy)
|
saveMod := enemySpellSaveMod(enemy)
|
||||||
saveRoll := 1 + rand.IntN(20)
|
saveRoll := 1 + simIntN(20)
|
||||||
saved := saveRoll+saveMod >= dc
|
saved := saveRoll+saveMod >= dc
|
||||||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||||
if saved {
|
if saved {
|
||||||
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
|
|||||||
}
|
}
|
||||||
total := 0
|
total := 0
|
||||||
for i := 0; i < darts; i++ {
|
for i := 0; i < darts; i++ {
|
||||||
total += 1 + rand.IntN(4) + 1
|
total += 1 + simIntN(4) + 1
|
||||||
}
|
}
|
||||||
mods.SpellPreDamage += total
|
mods.SpellPreDamage += total
|
||||||
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, 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).
|
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||||
saveMod := enemySpellSaveMod(enemy)
|
saveMod := enemySpellSaveMod(enemy)
|
||||||
saveRoll := 1 + rand.IntN(20)
|
saveRoll := 1 + simIntN(20)
|
||||||
if saveRoll+saveMod >= dc {
|
if saveRoll+saveMod >= dc {
|
||||||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||||
return
|
return
|
||||||
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
|
|||||||
if supreme {
|
if supreme {
|
||||||
heal += faces
|
heal += faces
|
||||||
} else {
|
} else {
|
||||||
heal += 1 + rand.IntN(faces)
|
heal += 1 + simIntN(faces)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
heal += abilityModifier(c.WIS)
|
heal += abilityModifier(c.WIS)
|
||||||
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
|||||||
}
|
}
|
||||||
total := flat
|
total := flat
|
||||||
for i := 0; i < dice; i++ {
|
for i := 0; i < dice; i++ {
|
||||||
total += 1 + rand.IntN(faces)
|
total += 1 + simIntN(faces)
|
||||||
}
|
}
|
||||||
if total < 1 {
|
if total < 1 {
|
||||||
total = 1
|
total = 1
|
||||||
|
|||||||
@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
|
|||||||
wantFlatStart int
|
wantFlatStart int
|
||||||
wantInitBias float64
|
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,
|
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
|
||||||
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
||||||
// burst now also scales with level, and Warlock picked up +1 attack.
|
// 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
|
// 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
|
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
|
||||||
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
|
// (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},
|
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
||||||
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
||||||
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
|
// 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
|
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
|
||||||
// Ranger as the "read prey tells" half.
|
// Ranger as the "read prey tells" half.
|
||||||
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
||||||
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0},
|
{ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
|
||||||
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1},
|
{ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
|
||||||
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0},
|
{ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
|
||||||
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 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 {
|
for _, tc := range cases {
|
||||||
stats := CombatStats{AttackBonus: 5}
|
stats := CombatStats{AttackBonus: 5}
|
||||||
|
|||||||
@@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra
|
|||||||
// while production paths use the package-global generator.
|
// while production paths use the package-global generator.
|
||||||
func rngFloat(rng *rand.Rand) float64 {
|
func rngFloat(rng *rand.Rand) float64 {
|
||||||
if rng == nil {
|
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()
|
return rng.Float64()
|
||||||
}
|
}
|
||||||
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
return rand.IntN(n)
|
return simIntN(n)
|
||||||
}
|
}
|
||||||
return rng.IntN(n)
|
return rng.IntN(n)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
|
|||||||
|
|
||||||
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
||||||
func newRunID() string {
|
func newRunID() string {
|
||||||
|
if simSeedOn() {
|
||||||
|
return simHexToken()
|
||||||
|
}
|
||||||
var b [8]byte
|
var b [8]byte
|
||||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
// Fall back to math/rand if /dev/urandom is unavailable.
|
// Fall back to math/rand if /dev/urandom is unavailable.
|
||||||
@@ -240,8 +243,12 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
|
if simSeedOn() {
|
||||||
|
rng = simZoneRNG()
|
||||||
|
} else {
|
||||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
seq := generateRoomSequence(zone, rng)
|
seq := generateRoomSequence(zone, rng)
|
||||||
|
|
||||||
startMood := 50
|
startMood := 50
|
||||||
|
|||||||
@@ -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
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
|
||||||
case ClassRogue, ClassRanger:
|
case ClassRogue, ClassRanger:
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
|
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
|
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:
|
case ClassCleric, ClassDruid:
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
||||||
case ClassBard, ClassWarlock:
|
case ClassBard, ClassWarlock:
|
||||||
|
|||||||
115
internal/plugin/sim_seed.go
Normal file
115
internal/plugin/sim_seed.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user