Combat: characterization test pinning the auto-resolve event stream

Splits SimulateCombat into a thin wrapper over simulateCombatWithRNG, a
deterministic core that accepts an optional *rand.Rand. Production passes
nil (package-global rand, behaviorally identical); the new test seeds it
per scenario.

Adds TestCombatCharacterization: 23 curated scenarios x 5 seeds, with the
full event stream + result summary serialized to a stable text form and
diffed against a golden file. This locks current auto-resolve behavior
before the shared-primitives extraction for the turn-based engine — any
perturbation fails loudly and forces a deliberate -update + diff review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-13 23:00:35 -07:00
parent 0ca17628d4
commit 6141964c58
3 changed files with 2384 additions and 31 deletions

View File

@@ -5,11 +5,11 @@ import "math/rand/v2"
// ── Core Types ───────────────────────────────────────────────────────────────
type CombatStats struct {
MaxHP int
MaxHP int
// StartHP is the HP the combatant *enters* this fight at. Zero means
// "use MaxHP" (full health). Wounded carry-over uses this so MaxHP
// stays stable across fights — display reads "100/123", not "100/100".
StartHP int
StartHP int
// HPBonus is the absolute HP players gain from equipment / arena sets /
// housing on top of their D&D character sheet HP. DerivePlayerStats
// computes this; applyDnDPlayerLayer adds it to c.HPMax to form the
@@ -39,29 +39,29 @@ type CombatStats struct {
}
type CombatModifiers struct {
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
DeathSave bool // Sovereign reprieve — survive one lethal hit
PetAttackProc float64
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
SniperKillProc float64 // Arina instant-kill
MistyHealProc float64
MistyHealAmt int
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
DeathSave bool // Sovereign reprieve — survive one lethal hit
PetAttackProc float64
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
SniperKillProc float64 // Arina instant-kill
MistyHealProc float64
MistyHealAmt int
CrowdRevengeProc float64 // Misty debuff: chance per round of crowd damage
CrowdRevengeDmg int // Misty debuff: damage per proc
HealItem int // consumable: HP restored per heal trigger
CrowdRevengeDmg int // Misty debuff: damage per proc
HealItem int // consumable: HP restored per heal trigger
// HealItemCharges is the number of times the heal-at-<50%-HP trigger
// can fire. 1 = legacy one-shot. Boss fights set this from inventory
// healing-item count so a stocked-up player can sustain through long
// fights instead of the heal becoming a single weak trigger.
HealItemCharges int
HealItemCharges int
// InitiativeBias adds to the player's initiative roll each round.
// Used by the DM mood system to tilt who-goes-first: positive favors
// the player (Effusive mood), negative favors the enemy (Hostile).
InitiativeBias float64
WardCharges int // consumable: hits fully absorbed
InitiativeBias float64
WardCharges int // consumable: hits fully absorbed
SporeCloud int // consumable: rounds of 15% enemy miss chance
ReflectNext float64 // consumable: fraction of next hit reflected
AutoCritFirst bool // consumable: first player hit is auto-crit
@@ -164,7 +164,7 @@ type CombatEvent struct {
}
type CombatResult struct {
PlayerWon bool
PlayerWon bool
// TimedOut is true when the fight ran out the phase clock without a
// kill on either side and was decided by the HP-percentage tiebreak.
// Callers should treat a timeout loss as a retreat / escape — the
@@ -195,9 +195,9 @@ type CombatResult struct {
type MonsterAbility struct {
Name string
Phase string // "opening", "clash", "decisive", "any"
Phase string // "opening", "clash", "decisive", "any"
ProcChance float64
Effect string // "poison", "enrage", "armor_break", "stun", "lifesteal", "cleave"
Effect string // "poison", "enrage", "armor_break", "stun", "lifesteal", "cleave"
}
// ── Default Phase Definitions ────────────────────────────────────────────────
@@ -242,17 +242,17 @@ type combatState struct {
// Consumable one-shots
healChargesLeft int // remaining heal-at-<50% triggers
wardCharges int
sporeRounds int
reflectFrac float64
autoCrit bool
wardCharges int
sporeRounds int
reflectFrac float64
autoCrit bool
// Monster ability effects
poisonTicks int
poisonDmg int
stunPlayer bool
enraged bool
armorBroken bool
poisonTicks int
poisonDmg int
stunPlayer bool
enraged bool
armorBroken bool
armorBreakAmt float64
// Sovereign reprieve
@@ -295,6 +295,15 @@ 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 {
return simulateCombatWithRNG(player, enemy, phases, nil)
}
// simulateCombatWithRNG is the deterministic core of the auto-resolve engine.
// SimulateCombat passes nil (package-global rand — production auto-resolve).
// The characterization test and the turn-based engine pass a seeded *rand.Rand
// so a fight is fully reproducible. Passing nil is behaviorally identical to
// the pre-injection code.
func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
playerStart := player.Stats.MaxHP
if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP {
playerStart = player.Stats.StartHP
@@ -304,14 +313,15 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
enemyStart = enemy.Stats.StartHP
}
st := &combatState{
playerHP: playerStart,
enemyHP: enemyStart,
playerHP: playerStart,
enemyHP: enemyStart,
wardCharges: player.Mods.WardCharges,
sporeRounds: player.Mods.SporeCloud,
reflectFrac: player.Mods.ReflectNext,
autoCrit: player.Mods.AutoCritFirst,
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
arcaneWardHP: player.Mods.ArcaneWardHP,
rng: rng,
}
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
// to 1 charge if the caller set a HealItem amount but no count.

View File

@@ -0,0 +1,296 @@
package plugin
// Characterization test for the auto-resolve combat engine.
//
// This pins the *exact* event stream SimulateCombat produces for a curated set
// of scenarios, driven by a seeded RNG so the output is reproducible. It is a
// safety net for the upcoming shared-primitives extraction (turn-based engine
// work): any change that perturbs the auto-resolve event sequence will fail
// here, forcing a deliberate `-update` regeneration + diff review.
//
// It asserts behavior, not correctness — a failing diff means "you changed
// something", not "you broke something". Review the diff, and if the change is
// intended, regenerate with:
//
// go test ./internal/plugin -run TestCombatCharacterization -update
import (
"flag"
"fmt"
"math/rand/v2"
"os"
"path/filepath"
"strings"
"testing"
)
var updateCharacterization = flag.Bool("update", false, "regenerate combat characterization golden file")
// charScenario is one pinned combat setup. The same player/enemy/phases are
// run across a fixed set of seeds so the snapshot captures multiple RNG
// branches (hit/miss/crit/proc) of the same configuration.
type charScenario struct {
name string
player Combatant
enemy Combatant
phases []CombatPhase
}
func characterizationScenarios() []charScenario {
// Local builders so the scenarios don't depend on mutation order of the
// shared basePlayer()/baseEnemy() helpers.
weaponPlayer := func() Combatant {
p := basePlayer()
p.Stats.Weapon = weaponByID("wpn_longsword")
return p
}
return []charScenario{
{"basic", basePlayer(), baseEnemy(), defaultCombatPhases},
{"dungeon_phases", basePlayer(), baseEnemy(), dungeonCombatPhases},
{"boss_phases", func() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 220
e.Stats.Attack = 22
return basePlayer()
}(), func() Combatant {
e := baseEnemy()
e.Name = "Boss"
e.Stats.MaxHP = 220
e.Stats.Attack = 22
return e
}(), bossCombatPhases},
{"strong_enemy", func() Combatant {
p := basePlayer()
p.Stats.MaxHP = 50
p.Stats.Attack = 8
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 200
e.Stats.Attack = 30
e.Stats.Defense = 12
return e
}(), defaultCombatPhases},
{"sniper_kill", func() Combatant {
p := basePlayer()
p.Mods.SniperKillProc = 1.0
return p
}(), baseEnemy(), defaultCombatPhases},
{"flat_dmg_start", func() Combatant {
p := basePlayer()
p.Mods.FlatDmgStart = 20
return p
}(), baseEnemy(), defaultCombatPhases},
{"spell_pre_damage", func() Combatant {
p := basePlayer()
p.Mods.SpellPreDamage = 18
p.Mods.SpellPreDamageDesc = "Fireball"
return p
}(), baseEnemy(), defaultCombatPhases},
{"spell_enemy_skip_first", func() Combatant {
p := basePlayer()
p.Mods.SpellPreDamage = 5
p.Mods.SpellPreDamageDesc = "Hold Person"
p.Mods.SpellEnemySkipFirst = true
return p
}(), baseEnemy(), defaultCombatPhases},
{"heal_item", func() Combatant {
p := basePlayer()
p.Mods.HealItem = 30
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 20
return e
}(), defaultCombatPhases},
{"ward_absorb", func() Combatant {
p := basePlayer()
p.Mods.WardCharges = 1
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 30
return e
}(), defaultCombatPhases},
{"spore_cloud", func() Combatant {
p := basePlayer()
p.Mods.SporeCloud = 10
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 15
return e
}(), defaultCombatPhases},
{"reflect_next", func() Combatant {
p := basePlayer()
p.Mods.ReflectNext = 0.5
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 20
return e
}(), defaultCombatPhases},
{"auto_crit_first", func() Combatant {
p := basePlayer()
p.Mods.AutoCritFirst = true
p.Stats.CritRate = 0
return p
}(), baseEnemy(), defaultCombatPhases},
{"pet_attack", func() Combatant {
p := basePlayer()
p.Mods.PetAttackProc = 1.0
p.Mods.PetAttackDmg = 10
return p
}(), baseEnemy(), defaultCombatPhases},
{"misty_heal", func() Combatant {
p := basePlayer()
p.Mods.MistyHealProc = 1.0
p.Mods.MistyHealAmt = 10
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 15
return e
}(), defaultCombatPhases},
{"death_save", func() Combatant {
p := basePlayer()
p.Stats.MaxHP = 30
p.Mods.DeathSave = true
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.Attack = 40
e.Stats.MaxHP = 200
return e
}(), defaultCombatPhases},
{"ability_poison", basePlayer(), func() Combatant {
e := baseEnemy()
e.Ability = &MonsterAbility{Name: "Venom Bite", Phase: "any", ProcChance: 1.0, Effect: "poison"}
return e
}(), defaultCombatPhases},
{"ability_enrage", func() Combatant {
p := basePlayer()
p.Stats.Attack = 12
return p
}(), func() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 50
e.Stats.Attack = 6
e.Ability = &MonsterAbility{Name: "Berserk", Phase: "any", ProcChance: 1.0, Effect: "enrage"}
return e
}(), defaultCombatPhases},
{"ability_armor_break", basePlayer(), func() Combatant {
e := baseEnemy()
e.Ability = &MonsterAbility{Name: "Rend", Phase: "opening", ProcChance: 1.0, Effect: "armor_break"}
return e
}(), defaultCombatPhases},
{"ability_stun", basePlayer(), func() Combatant {
e := baseEnemy()
e.Ability = &MonsterAbility{Name: "Bash", Phase: "any", ProcChance: 1.0, Effect: "stun"}
return e
}(), defaultCombatPhases},
{"ability_lifesteal", basePlayer(), func() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 100
e.Ability = &MonsterAbility{Name: "Drain", Phase: "any", ProcChance: 1.0, Effect: "lifesteal"}
return e
}(), defaultCombatPhases},
{"ability_cleave", basePlayer(), func() Combatant {
e := baseEnemy()
e.Ability = &MonsterAbility{Name: "Cleave", Phase: "clash", ProcChance: 1.0, Effect: "cleave"}
return e
}(), defaultCombatPhases},
{"weapon_profile", weaponPlayer(), baseEnemy(), defaultCombatPhases},
}
}
// charSeeds drives each scenario. Multiple seeds widen RNG-branch coverage
// without exploding the golden file.
var charSeeds = []uint64{1, 2, 3, 7, 42}
func TestCombatCharacterization(t *testing.T) {
var b strings.Builder
for _, sc := range characterizationScenarios() {
for _, seed := range charSeeds {
// PCG with a per-(scenario,seed) stream so scenarios don't share
// a sequence. The literal constant just disambiguates the stream.
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
res := simulateCombatWithRNG(sc.player, sc.enemy, sc.phases, rng)
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
b.WriteString(formatResult(res))
b.WriteString("\n")
}
}
got := b.String()
goldenPath := filepath.Join("testdata", "combat_characterization.golden")
if *updateCharacterization {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
t.Logf("updated %s", goldenPath)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run with -update to create): %v", err)
}
if got != string(want) {
t.Errorf("combat event stream diverged from golden file.\n"+
"If this change is intentional, regenerate with:\n"+
" go test ./internal/plugin -run TestCombatCharacterization -update\n"+
"then review the diff.\n\n%s", firstDiff(string(want), got))
}
}
// formatResult serializes a CombatResult to a stable, line-oriented text form.
// Every event is dumped; summary fields follow. Field order is fixed so a diff
// pinpoints exactly what moved.
func formatResult(r CombatResult) string {
var b strings.Builder
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d\n", r.PlayerWon, r.TimedOut, r.TotalRounds)
fmt.Fprintf(&b, " hp: playerStart=%d playerEntry=%d playerEnd=%d enemyStart=%d enemyEntry=%d enemyEnd=%d\n",
r.PlayerStartHP, r.PlayerEntryHP, r.PlayerEndHP, r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
fmt.Fprintf(&b, " flags: nearDeath=%v pet=%v petDeflect=%v sniper=%v misty=%v closeness=%.4f\n",
r.NearDeath, r.PetAttacked, r.PetDeflected, r.SniperKilled, r.MistyHealed, r.Closeness)
for i, e := range r.Events {
fmt.Fprintf(&b, " [%02d] r%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
i, e.Round, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP, e.Roll, e.RollAgainst, e.Desc)
}
return b.String()
}
// firstDiff returns a short window around the first differing line, so test
// output points straight at the divergence instead of dumping the whole file.
func firstDiff(want, got string) string {
wl := strings.Split(want, "\n")
gl := strings.Split(got, "\n")
for i := 0; i < len(wl) || i < len(gl); i++ {
var w, g string
if i < len(wl) {
w = wl[i]
}
if i < len(gl) {
g = gl[i]
}
if w != g {
lo := i - 3
if lo < 0 {
lo = 0
}
var b strings.Builder
fmt.Fprintf(&b, "first divergence at line %d:\n", i+1)
for j := lo; j < i; j++ {
if j < len(gl) {
fmt.Fprintf(&b, " ctx: %s\n", gl[j])
}
}
fmt.Fprintf(&b, " want: %s\n", w)
fmt.Fprintf(&b, " got: %s\n", g)
return b.String()
}
}
return "(files differ but no line-level diff found — trailing whitespace?)"
}

File diff suppressed because it is too large Load Diff