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:
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