mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
The roster refactor (N3 parties) has to prove it doesn't move solo
combat, or the d8prereq balance corpus dies with it. The existing
golden pinned 22 scenarios; it missed everything the refactor is most
likely to disturb:
- ExtraAttacks (the lever J1 moved) and the rest of the 2026-05-16
class-identity mods: sneak attack, hunter's mark, divine strike,
thorn lash, crit threshold, first-attack bonus, assassinate,
berserker rage, spirit weapon, arcane ward, heal charges
- the race passives (lucky reroll, orc rage, poison/fire resist)
- all 13 Phase 13 bestiary slice 3+4 effects, none of which had a
pin: evade, block, advantage, retaliate, regenerate, survive_at_1,
stat_drain, debuff, max_hp_drain, spell_resist, reveal_action,
fear_immune, ally_buff
- phase-scoped ability gating and the environment hazard path
22 -> 57 scenarios, 2047 -> 7468 golden lines. The pre-existing golden
is a byte-exact prefix of the new one, so no existing pin moved.
Enemies are sized per scenario (tanky/hardHit) so per-hit and
per-swing riders accumulate instead of dying in the opening round, and
abilities proc at 1.0 so the pin captures the effect, not the roll.
Verified every new scenario emits its distinctive event.
426 lines
16 KiB
Go
426 lines
16 KiB
Go
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},
|
|
|
|
// --- Class-identity mods (2026-05-16 audit + J1). ExtraAttacks is the
|
|
// lever J1 moved, so it needs a pin before the roster refactor.
|
|
{"extra_attacks_1", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 1 }), tankyEnemy(), defaultCombatPhases},
|
|
{"extra_attacks_3", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 3 }), tankyEnemy(), defaultCombatPhases},
|
|
{"sneak_attack", modPlayer(func(m *CombatModifiers) { m.SneakAttackDie = 4 }), tankyEnemy(), defaultCombatPhases},
|
|
{"hunters_mark", modPlayer(func(m *CombatModifiers) { m.HuntersMarkDie = 3 }), tankyEnemy(), defaultCombatPhases},
|
|
{"divine_strike", weaponMods(func(m *CombatModifiers) { m.DivineStrikePerHit = 4 }), tankyEnemy(), defaultCombatPhases},
|
|
{"thorn_lash", modPlayer(func(m *CombatModifiers) { m.ThornLashDmg = 3 }), hardHitEnemy(), defaultCombatPhases},
|
|
{"crit_threshold_19", modPlayer(func(m *CombatModifiers) { m.CritThreshold = 19 }), tankyEnemy(), defaultCombatPhases},
|
|
{"first_attack_bonus", modPlayer(func(m *CombatModifiers) { m.FirstAttackBonus = 8 }), tankyEnemy(), defaultCombatPhases},
|
|
{"assassinate", modPlayer(func(m *CombatModifiers) {
|
|
m.AssassinateAdvantage = true
|
|
m.AssassinateBonusDmg = 12
|
|
}), tankyEnemy(), defaultCombatPhases},
|
|
{"berserker_rage", modPlayer(func(m *CombatModifiers) {
|
|
m.BerserkerRage = true
|
|
m.RageMeleeDmg = 2
|
|
m.PhysicalResistRage = true
|
|
m.FrenzyDmgBonus = 0.25
|
|
}), hardHitEnemy(), defaultCombatPhases},
|
|
{"spirit_weapon", modPlayer(func(m *CombatModifiers) {
|
|
m.SpiritWeaponProc = 1.0
|
|
m.SpiritWeaponDmg = 8
|
|
}), tankyEnemy(), defaultCombatPhases},
|
|
{"arcane_ward", modPlayer(func(m *CombatModifiers) { m.ArcaneWardHP = 25 }), hardHitEnemy(), defaultCombatPhases},
|
|
{"heal_charges_3", modPlayer(func(m *CombatModifiers) {
|
|
m.HealItem = 20
|
|
m.HealItemCharges = 3
|
|
}), hardHitEnemy(), defaultCombatPhases},
|
|
{"crowd_revenge", modPlayer(func(m *CombatModifiers) {
|
|
m.CrowdRevengeProc = 1.0
|
|
m.CrowdRevengeDmg = 4
|
|
}), baseEnemy(), defaultCombatPhases},
|
|
|
|
// --- Race passives.
|
|
{"lucky_reroll", modPlayer(func(m *CombatModifiers) { m.LuckyReroll = true }), tankyEnemy(), defaultCombatPhases},
|
|
{"orc_rage", modPlayer(func(m *CombatModifiers) { m.RageReady = true }), hardHitEnemy(), defaultCombatPhases},
|
|
{"poison_resist", modPlayer(func(m *CombatModifiers) { m.PoisonResist = true }), abilityEnemy("Venom", "poison", "any"), defaultCombatPhases},
|
|
{"fire_resist_aoe", modPlayer(func(m *CombatModifiers) { m.FireResist = true }), abilityEnemy("Flame Breath", "aoe_fire", "any"), defaultCombatPhases},
|
|
|
|
// --- Phase 13 bestiary slices 3 + 4. None of these were pinned.
|
|
{"ability_bonus_damage", basePlayer(), abilityEnemy("Smash", "bonus_damage", "any"), defaultCombatPhases},
|
|
{"ability_aoe", basePlayer(), abilityEnemy("Blast", "aoe", "any"), defaultCombatPhases},
|
|
{"ability_death_aoe", basePlayer(), abilityEnemy("Last Breath", "death_aoe", "any"), defaultCombatPhases},
|
|
{"ability_execute", func() Combatant {
|
|
p := basePlayer()
|
|
p.Stats.MaxHP = 40
|
|
return p
|
|
}(), abilityEnemy("Finisher", "execute", "any"), defaultCombatPhases},
|
|
{"ability_self_heal", basePlayer(), abilityEnemy("Mend", "self_heal", "any"), defaultCombatPhases},
|
|
{"ability_evade", basePlayer(), abilityEnemy("Blink", "evade", "any"), defaultCombatPhases},
|
|
{"ability_block", basePlayer(), abilityEnemy("Parry", "block", "any"), defaultCombatPhases},
|
|
{"ability_advantage", basePlayer(), abilityEnemy("Focus", "advantage", "any"), defaultCombatPhases},
|
|
{"ability_retaliate", basePlayer(), abilityEnemy("Spines", "retaliate", "any"), defaultCombatPhases},
|
|
{"ability_regenerate", basePlayer(), abilityEnemy("Regrow", "regenerate", "any"), defaultCombatPhases},
|
|
{"ability_survive_at_1", basePlayer(), func() Combatant {
|
|
e := abilityEnemy("Undying", "survive_at_1", "any")
|
|
e.Stats.MaxHP = 40
|
|
return e
|
|
}(), defaultCombatPhases},
|
|
{"ability_stat_drain", basePlayer(), abilityEnemy("Sap", "stat_drain", "any"), defaultCombatPhases},
|
|
{"ability_debuff", basePlayer(), abilityEnemy("Curse", "debuff", "any"), defaultCombatPhases},
|
|
{"ability_max_hp_drain", basePlayer(), abilityEnemy("Wither", "max_hp_drain", "any"), defaultCombatPhases},
|
|
{"ability_spell_resist", func() Combatant {
|
|
p := basePlayer()
|
|
p.Mods.SpellPreDamage = 30
|
|
p.Mods.SpellPreDamageDesc = "Fireball"
|
|
return p
|
|
}(), abilityEnemy("Warded", "spell_resist", "any"), defaultCombatPhases},
|
|
{"ability_reveal_action", basePlayer(), abilityEnemy("Expose", "reveal_action", "any"), defaultCombatPhases},
|
|
{"ability_fear_immune", func() Combatant {
|
|
p := basePlayer()
|
|
p.Mods.SpellPreDamage = 5
|
|
p.Mods.SpellPreDamageDesc = "Hold Person"
|
|
p.Mods.SpellEnemySkipFirst = true
|
|
return p
|
|
}(), abilityEnemy("Fearless", "fear_immune", "any"), defaultCombatPhases},
|
|
{"ability_ally_buff", basePlayer(), abilityEnemy("Warcry", "ally_buff", "any"), defaultCombatPhases},
|
|
|
|
// --- Phase-scoped ability gating + environment hazard.
|
|
{"ability_opening_phase", basePlayer(), abilityEnemy("Ambush", "bonus_damage", "opening"), defaultCombatPhases},
|
|
{"environment_heavy", basePlayer(), baseEnemy(), []CombatPhase{
|
|
{"Opening", 2, 0.6, 0.8, 1.5, 1.0},
|
|
{"Clash", 3, 1.2, 1.0, 0.8, 1.0},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// modPlayer returns basePlayer with a mutation applied to its modifiers.
|
|
func modPlayer(f func(*CombatModifiers)) Combatant {
|
|
p := basePlayer()
|
|
f(&p.Mods)
|
|
return p
|
|
}
|
|
|
|
// weaponMods is modPlayer for effects that only fire on the weapon-dice
|
|
// damage path (Divine Strike has no effect without a Weapon).
|
|
func weaponMods(f func(*CombatModifiers)) Combatant {
|
|
p := basePlayer()
|
|
p.Stats.Weapon = weaponByID("wpn_longsword")
|
|
f(&p.Mods)
|
|
return p
|
|
}
|
|
|
|
// tankyEnemy survives long enough for per-hit and per-swing riders to
|
|
// accumulate across several rounds instead of dying in the opening.
|
|
func tankyEnemy() Combatant {
|
|
e := baseEnemy()
|
|
e.Stats.MaxHP = 300
|
|
e.Stats.Defense = 10
|
|
return e
|
|
}
|
|
|
|
// hardHitEnemy hits hard enough to exercise damage-taken paths (wards,
|
|
// resists, retaliation, heal triggers) before the fight resolves.
|
|
func hardHitEnemy() Combatant {
|
|
e := baseEnemy()
|
|
e.Stats.MaxHP = 150
|
|
e.Stats.Attack = 28
|
|
return e
|
|
}
|
|
|
|
// abilityEnemy is baseEnemy carrying an always-proccing ability, so the
|
|
// golden pins the effect rather than the proc roll.
|
|
func abilityEnemy(name, effect, phase string) Combatant {
|
|
e := baseEnemy()
|
|
e.Stats.MaxHP = 120
|
|
e.Ability = &MonsterAbility{Name: name, Phase: phase, ProcChance: 1.0, Effect: effect}
|
|
return e
|
|
}
|
|
|
|
// 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?)"
|
|
}
|