Combat: inject per-session RNG into the engine

Adds an optional *rand.Rand to combatState so a fight can be driven by a
deterministic source. Auto-resolve leaves it nil and falls through to the
package global in the same call order — behaviorally identical. Threads
the source through calcDamage and rollWeaponDamage.

Groundwork for the turn-based elite/boss engine and its timeout reaper,
which seed the rng per combat_session to make fights resumable and
replayable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-13 22:53:06 -07:00
parent 4e412219f3
commit 4af110cc20
3 changed files with 49 additions and 34 deletions

View File

@@ -277,8 +277,23 @@ type combatState struct {
round int round int
events []CombatEvent events []CombatEvent
// rng, when non-nil, is the deterministic source the turn-based engine
// and the timeout reaper seed per session so a fight can be resumed and
// replayed reproducibly. Auto-resolve (SimulateCombat called without a
// session) leaves this nil and the engine falls back to the package
// global — behaviorally identical to the pre-injection code.
rng *rand.Rand
} }
// roll / randFloat draw from the session's injected rng when present,
// else the package global (see rngIntN / rngFloat in dnd_zone_loot.go).
// Auto-resolve leaves st.rng nil — behaviorally identical to the
// pre-injection code; the turn-based engine and the timeout reaper seed
// it per session so a fight can be resumed and replayed reproducibly.
func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) }
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult { func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult {
playerStart := player.Stats.MaxHP playerStart := player.Stats.MaxHP
if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP { if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP {
@@ -313,7 +328,7 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
} }
// Pre-combat: Arina sniper check // Pre-combat: Arina sniper check
if player.Mods.SniperKillProc > 0 && rand.Float64() < player.Mods.SniperKillProc { if player.Mods.SniperKillProc > 0 && st.randFloat() < player.Mods.SniperKillProc {
st.enemyHP = 0 st.enemyHP = 0
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill", Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
@@ -361,7 +376,7 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
roundsThisPhase := phase.Rounds roundsThisPhase := phase.Rounds
// Add slight variance: ±1 round for non-Decisive phases // Add slight variance: ±1 round for non-Decisive phases
if phase.Name != "Decisive" && roundsThisPhase > 1 { if phase.Name != "Decisive" && roundsThisPhase > 1 {
roundsThisPhase += rand.IntN(2) // 0 or +1 roundsThisPhase += st.roll(2) // 0 or +1
} }
for r := 0; r < roundsThisPhase; r++ { for r := 0; r < roundsThisPhase; r++ {
st.round++ st.round++
@@ -444,23 +459,23 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
} }
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss // Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
petWhiff := player.Mods.PetWhiffProc > 0 && rand.Float64() < player.Mods.PetWhiffProc petWhiff := player.Mods.PetWhiffProc > 0 && st.randFloat() < player.Mods.PetWhiffProc
// Pet deflect: halves incoming damage to player this round // Pet deflect: halves incoming damage to player this round
petDeflect := player.Mods.PetDeflectProc > 0 && rand.Float64() < player.Mods.PetDeflectProc petDeflect := player.Mods.PetDeflectProc > 0 && st.randFloat() < player.Mods.PetDeflectProc
if petDeflect { if petDeflect {
result.PetDeflected = true result.PetDeflected = true
} }
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks) // Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
sporeMiss := st.sporeRounds > 0 && rand.Float64() < 0.15 sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
// Determine initiative. DM mood (Effusive/Hostile) biases the player's // Determine initiative. DM mood (Effusive/Hostile) biases the player's
// roll via player.Mods.InitiativeBias — +X means player goes first // roll via player.Mods.InitiativeBias — +X means player goes first
// more often, -X means the enemy does. // more often, -X means the enemy does.
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
playerInit := playerSpeed + rand.Float64()*10 + player.Mods.InitiativeBias playerInit := playerSpeed + st.randFloat()*10 + player.Mods.InitiativeBias
enemyInit := enemySpeed + rand.Float64()*10 enemyInit := enemySpeed + st.randFloat()*10
playerFirst := playerInit >= enemyInit playerFirst := playerInit >= enemyInit
if playerFirst { if playerFirst {
@@ -484,8 +499,8 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
} }
// Environmental hazard // Environmental hazard
if phase.EnvironmentProc > 0 && rand.Float64() < phase.EnvironmentProc { if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
envDmg := 2 + rand.IntN(5) envDmg := 2 + st.roll(5)
st.playerHP = max(0, st.playerHP-envDmg) st.playerHP = max(0, st.playerHP-envDmg)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental", Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
@@ -499,7 +514,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
} }
// Misty crowd revenge (debuff for declining Misty) // Misty crowd revenge (debuff for declining Misty)
if player.Mods.CrowdRevengeProc > 0 && rand.Float64() < player.Mods.CrowdRevengeProc { if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
dmg := player.Mods.CrowdRevengeDmg dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg) st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
@@ -515,8 +530,8 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
} }
// Pet attack // Pet attack
if player.Mods.PetAttackProc > 0 && rand.Float64() < player.Mods.PetAttackProc { if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
petDmg := player.Mods.PetAttackDmg + rand.IntN(5) petDmg := player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg) st.enemyHP = max(0, st.enemyHP-petDmg)
result.PetAttacked = true result.PetAttacked = true
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
@@ -529,7 +544,7 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase
} }
// Misty heal // Misty heal
if player.Mods.MistyHealProc > 0 && rand.Float64() < player.Mods.MistyHealProc { if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt healAmt := player.Mods.MistyHealAmt
st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt) st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt)
result.MistyHealed = true result.MistyHealed = true
@@ -592,11 +607,11 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
}) })
} }
roll := 1 + rand.IntN(20) roll := 1 + st.roll(20)
// Halfling Lucky: reroll the first nat 1 of the fight. // Halfling Lucky: reroll the first nat 1 of the fight.
if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed { if roll == 1 && player.Mods.LuckyReroll && !st.luckyUsed {
st.luckyUsed = true st.luckyUsed = true
newRoll := 1 + rand.IntN(20) newRoll := 1 + st.roll(20)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "lucky_reroll", Round: st.round, Phase: phaseName, Actor: "player", Action: "lucky_reroll",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
@@ -609,7 +624,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
// creatures that haven't acted yet" applied to the opening strike only. // creatures that haven't acted yet" applied to the opening strike only.
if player.Mods.AssassinateAdvantage && !st.assassinateRerollUsed { if player.Mods.AssassinateAdvantage && !st.assassinateRerollUsed {
st.assassinateRerollUsed = true st.assassinateRerollUsed = true
alt := 1 + rand.IntN(20) alt := 1 + st.roll(20)
if alt > roll { if alt > roll {
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "player", Action: "assassinate_advantage", Round: st.round, Phase: phaseName, Actor: "player", Action: "assassinate_advantage",
@@ -661,7 +676,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
if !player.Stats.WeaponProficient { if !player.Stats.WeaponProficient {
mod = 0 mod = 0
} }
total, _ := rollWeaponDamage(player.Stats.Weapon, mod, player.Stats.TwoHandedMode) total, _ := rollWeaponDamage(st.rng, player.Stats.Weapon, mod, player.Stats.TwoHandedMode)
dmg = total dmg = total
// Class damage bonus / streak bonus / etc. layered on top via DamageBonus. // Class damage bonus / streak bonus / etc. layered on top via DamageBonus.
if player.Mods.DamageBonus > 0 { if player.Mods.DamageBonus > 0 {
@@ -672,11 +687,11 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
dmg = int(float64(dmg) * enemy.Mods.DamageReduct) dmg = int(float64(dmg) * enemy.Mods.DamageReduct)
} }
} else { } else {
dmg = calcDamage(player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus, dmg = calcDamage(st.rng, player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct) enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
} }
blocked := enemy.Stats.BlockRate > 0 && rand.Float64() < enemy.Stats.BlockRate blocked := enemy.Stats.BlockRate > 0 && st.randFloat() < enemy.Stats.BlockRate
if blocked { if blocked {
dmg = max(1, dmg/2) dmg = max(1, dmg/2)
} }
@@ -777,7 +792,7 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
return false return false
} }
roll := 1 + rand.IntN(20) roll := 1 + st.roll(20)
isFumble := roll == 1 isFumble := roll == 1
isNat20 := roll == 20 isNat20 := roll == 20
total := roll + enemy.Stats.AttackBonus total := roll + enemy.Stats.AttackBonus
@@ -808,10 +823,10 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
blocked := player.Stats.BlockRate > 0 && rand.Float64() < player.Stats.BlockRate blocked := player.Stats.BlockRate > 0 && st.randFloat() < player.Stats.BlockRate
if blocked { if blocked {
dmg = max(1, dmg/2) dmg = max(1, dmg/2)
} }
@@ -897,7 +912,7 @@ func abilityFires(ability *MonsterAbility, phaseName string, st *combatState) bo
if !phaseMatch { if !phaseMatch {
return false return false
} }
return rand.Float64() < ability.ProcChance return st.randFloat() < ability.ProcChance
} }
func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool { func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
@@ -907,7 +922,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
switch ab.Effect { switch ab.Effect {
case "poison": case "poison":
st.poisonTicks = 2 st.poisonTicks = 2
st.poisonDmg = 3 + rand.IntN(3) st.poisonDmg = 3 + st.roll(3)
if player.Mods.PoisonResist { if player.Mods.PoisonResist {
st.poisonDmg = max(1, st.poisonDmg/2) st.poisonDmg = max(1, st.poisonDmg/2)
} }
@@ -947,7 +962,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg = max(1, dmg) dmg = max(1, dmg)
st.playerHP = max(0, st.playerHP-dmg) st.playerHP = max(0, st.playerHP-dmg)
@@ -966,7 +981,7 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
if st.enraged { if st.enraged {
atkMult = 1.5 atkMult = 1.5
} }
dmg1 := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus, dmg1 := calcDamage(st.rng, int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct) playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
dmg1 = max(1, dmg1) dmg1 = max(1, dmg1)
st.playerHP = max(0, st.playerHP-dmg1) st.playerHP = max(0, st.playerHP-dmg1)
@@ -1004,13 +1019,13 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
// //
// A ±15% per-hit jitter is applied so successive hits in the same phase don't // A ±15% per-hit jitter is applied so successive hits in the same phase don't
// produce identical numbers — the previous flat-damage output read as scripted. // produce identical numbers — the previous flat-damage output read as scripted.
func calcDamage(attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int { func calcDamage(rng *rand.Rand, attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int {
const K = 40.0 const K = 40.0
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus) rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
effectiveDef := float64(defense) * defWeight * dmgReduct effectiveDef := float64(defense) * defWeight * dmgReduct
reduction := K / (K + effectiveDef) reduction := K / (K + effectiveDef)
dmg := rawAtk * reduction dmg := rawAtk * reduction
jitter := 0.85 + rand.Float64()*0.30 // 0.85 .. 1.15 jitter := 0.85 + rngFloat(rng)*0.30 // 0.85 .. 1.15
dmg *= jitter dmg *= jitter
if dmg < 1 { if dmg < 1 {
return 1 return 1

View File

@@ -239,13 +239,13 @@ func dndClassArmorProficiency(class DnDClass, a *ArmorProfile) bool {
// + magic bonus. If twoHanded is true and the weapon is versatile, rolls // + magic bonus. If twoHanded is true and the weapon is versatile, rolls
// the larger versatile die instead. Returns the unmodified dice total too // the larger versatile die instead. Returns the unmodified dice total too
// for the crit doubling math. // for the crit doubling math.
func rollWeaponDamage(w *WeaponProfile, abilityMod int, twoHanded bool) (total, dice int) { func rollWeaponDamage(rng *rand.Rand, w *WeaponProfile, abilityMod int, twoHanded bool) (total, dice int) {
count, sides := w.DamageCount, w.DamageSides count, sides := w.DamageCount, w.DamageSides
if twoHanded && w.HasProperty(PropVersatile) && w.VersaCount > 0 { if twoHanded && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
count, sides = w.VersaCount, w.VersaSides count, sides = w.VersaCount, w.VersaSides
} }
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
dice += 1 + rand.IntN(sides) dice += 1 + rngIntN(rng, sides)
} }
total = dice + abilityMod + w.MagicBonus total = dice + abilityMod + w.MagicBonus
if total < 1 { if total < 1 {

View File

@@ -179,7 +179,7 @@ func TestComputeArmorAC_MagicBonus(t *testing.T) {
func TestRollWeaponDamage_Bounds(t *testing.T) { func TestRollWeaponDamage_Bounds(t *testing.T) {
greatsword := weaponByID("wpn_greatsword") // 2d6 greatsword := weaponByID("wpn_greatsword") // 2d6
for i := 0; i < 1000; i++ { for i := 0; i < 1000; i++ {
total, dice := rollWeaponDamage(greatsword, 3, false) total, dice := rollWeaponDamage(nil, greatsword, 3, false)
if dice < 2 || dice > 12 { if dice < 2 || dice > 12 {
t.Fatalf("greatsword dice out of [2,12]: %d", dice) t.Fatalf("greatsword dice out of [2,12]: %d", dice)
} }
@@ -193,14 +193,14 @@ func TestRollWeaponDamage_VersatileTwoHanded(t *testing.T) {
longsword := weaponByID("wpn_longsword") // 1d8 / 1d10 longsword := weaponByID("wpn_longsword") // 1d8 / 1d10
// One-handed // One-handed
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
_, dice := rollWeaponDamage(longsword, 0, false) _, dice := rollWeaponDamage(nil, longsword, 0, false)
if dice < 1 || dice > 8 { if dice < 1 || dice > 8 {
t.Errorf("longsword 1H dice = %d, want [1,8]", dice) t.Errorf("longsword 1H dice = %d, want [1,8]", dice)
} }
} }
// Two-handed (versatile) // Two-handed (versatile)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
_, dice := rollWeaponDamage(longsword, 0, true) _, dice := rollWeaponDamage(nil, longsword, 0, true)
if dice < 1 || dice > 10 { if dice < 1 || dice > 10 {
t.Errorf("longsword 2H dice = %d, want [1,10]", dice) t.Errorf("longsword 2H dice = %d, want [1,10]", dice)
} }
@@ -211,7 +211,7 @@ func TestRollWeaponDamage_FloorAt1(t *testing.T) {
// Pathological: 1d4 with -10 mod always rolls 1+(-10) = negative, floored to 1. // Pathological: 1d4 with -10 mod always rolls 1+(-10) = negative, floored to 1.
dagger := weaponByID("wpn_dagger") dagger := weaponByID("wpn_dagger")
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
total, _ := rollWeaponDamage(dagger, -10, false) total, _ := rollWeaponDamage(nil, dagger, -10, false)
if total < 1 { if total < 1 {
t.Errorf("damage floor violated: %d", total) t.Errorf("damage floor violated: %d", total)
} }