Files
gogobee/internal/plugin/sim_seed.go
prosolis 1f62a8e842 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).
2026-07-17 16:17:41 -07:00

116 lines
4.3 KiB
Go

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()
}