Add forward-simulating combat engine with consumables, monster abilities, and narrative rendering

Replaces the single-roll probability system for dungeon and arena combat with
a multi-phase simulation engine where gear, buffs, pets, and NPCs are meaningful
mechanical inputs. Adds consumable items (auto-crafted from forage/mine/fish drops),
monster abilities for T2+ enemies, and Dragon Quest-style combat narrative with
phased message delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-18 01:05:22 -07:00
parent 08e9925d8f
commit 7c450aaefb
18 changed files with 4602 additions and 886 deletions

View File

@@ -0,0 +1,579 @@
package plugin
import (
"testing"
)
func basePlayer() Combatant {
return Combatant{
Name: "Hero",
IsPlayer: true,
Stats: CombatStats{
MaxHP: 100,
Attack: 15,
Defense: 8,
Speed: 10,
CritRate: 0.05,
DodgeRate: 0.05,
BlockRate: 0.05,
},
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
}
}
func baseEnemy() Combatant {
return Combatant{
Name: "Rat",
Stats: CombatStats{
MaxHP: 60,
Attack: 10,
Defense: 4,
Speed: 6,
CritRate: 0.03,
DodgeRate: 0.02,
BlockRate: 0.02,
},
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
}
}
func TestSimulateCombat_BasicResolves(t *testing.T) {
wins, losses := 0, 0
for i := 0; i < 1000; i++ {
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
if r.PlayerWon {
wins++
} else {
losses++
}
if r.TotalRounds == 0 && !r.SniperKilled {
t.Fatal("combat resolved in 0 rounds without sniper")
}
if len(r.Events) == 0 {
t.Fatal("no events generated")
}
}
if wins == 0 {
t.Error("player never won in 1000 fights against a weaker enemy")
}
if wins < 700 {
t.Errorf("player should strongly favor wins vs weaker enemy, got %d/1000", wins)
}
}
func TestSimulateCombat_HPTracking(t *testing.T) {
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
if r.PlayerStartHP != 100 {
t.Errorf("player start HP = %d, want 100", r.PlayerStartHP)
}
if r.EnemyStartHP != 60 {
t.Errorf("enemy start HP = %d, want 60", r.EnemyStartHP)
}
if r.PlayerWon && r.EnemyEndHP != 0 {
t.Errorf("player won but enemy HP = %d", r.EnemyEndHP)
}
if !r.PlayerWon && r.PlayerEndHP != 0 {
t.Errorf("player lost but player HP = %d", r.PlayerEndHP)
}
}
func TestSimulateCombat_SniperKill(t *testing.T) {
p := basePlayer()
p.Mods.SniperKillProc = 1.0 // guaranteed
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
if !r.PlayerWon {
t.Error("guaranteed sniper should always win")
}
if !r.SniperKilled {
t.Error("SniperKilled flag not set")
}
if len(r.Events) != 1 || r.Events[0].Action != "sniper_kill" {
t.Error("expected exactly 1 sniper_kill event")
}
}
func TestSimulateCombat_DeathSave(t *testing.T) {
// Player with death save against a very strong enemy
p := basePlayer()
p.Stats.MaxHP = 30
p.Mods.DeathSave = true
e := baseEnemy()
e.Stats.Attack = 40
e.Stats.MaxHP = 200
saves := 0
for i := 0; i < 500; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "death_save" {
saves++
if ev.PlayerHP != 1 {
t.Errorf("death save should set HP to 1, got %d", ev.PlayerHP)
}
break
}
}
}
if saves == 0 {
t.Error("death save never triggered in 500 fights against strong enemy")
}
}
func TestSimulateCombat_PetAttack(t *testing.T) {
p := basePlayer()
p.Mods.PetAttackProc = 1.0 // guaranteed every round
p.Mods.PetAttackDmg = 10
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
if !r.PetAttacked {
t.Error("pet attack should have triggered")
}
petHits := 0
for _, ev := range r.Events {
if ev.Action == "pet_attack" {
petHits++
}
}
if petHits == 0 {
t.Error("no pet_attack events")
}
}
func TestSimulateCombat_PetWhiff(t *testing.T) {
p := basePlayer()
p.Mods.PetWhiffProc = 1.0 // guaranteed
e := baseEnemy()
e.Stats.Attack = 5
whiffs := 0
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "pet_whiff" {
whiffs++
}
}
if whiffs == 0 {
t.Error("pet whiff never triggered with 100% proc rate")
}
}
func TestSimulateCombat_PetDeflect(t *testing.T) {
p := basePlayer()
p.Mods.PetDeflectProc = 1.0
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
if !r.PetDeflected {
t.Error("pet deflect should have triggered")
}
deflects := 0
for _, ev := range r.Events {
if ev.Action == "pet_deflect" {
deflects++
}
}
if deflects == 0 {
t.Error("no pet_deflect events")
}
}
func TestSimulateCombat_MistyHeal(t *testing.T) {
p := basePlayer()
p.Mods.MistyHealProc = 1.0
p.Mods.MistyHealAmt = 10
e := baseEnemy()
e.Stats.Attack = 15 // enough to do some damage
heals := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "misty_heal" {
heals++
}
}
}
if heals == 0 {
t.Error("Misty heal never triggered with 100% proc rate across 100 fights")
}
}
func TestSimulateCombat_FlatDmgStart(t *testing.T) {
p := basePlayer()
p.Mods.FlatDmgStart = 20
e := baseEnemy()
e.Stats.MaxHP = 60
r := SimulateCombat(p, e, defaultCombatPhases)
if len(r.Events) == 0 {
t.Fatal("no events")
}
first := r.Events[0]
if first.Action != "flat_damage" {
t.Errorf("first event should be flat_damage, got %s", first.Action)
}
if first.Damage != 20 {
t.Errorf("flat damage = %d, want 20", first.Damage)
}
if first.EnemyHP != 40 {
t.Errorf("enemy HP after flat damage = %d, want 40", first.EnemyHP)
}
}
func TestSimulateCombat_FlatDmgKill(t *testing.T) {
p := basePlayer()
p.Mods.FlatDmgStart = 999
e := baseEnemy()
e.Stats.MaxHP = 10
r := SimulateCombat(p, e, defaultCombatPhases)
if !r.PlayerWon {
t.Error("flat damage should kill a 10HP enemy")
}
if r.EnemyEndHP != 0 {
t.Errorf("enemy HP = %d, want 0", r.EnemyEndHP)
}
}
func TestSimulateCombat_HealItem(t *testing.T) {
p := basePlayer()
p.Stats.MaxHP = 100
p.Mods.HealItem = 30
e := baseEnemy()
e.Stats.Attack = 20 // will deal solid damage
heals := 0
for i := 0; i < 200; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "heal_item" {
heals++
if ev.Damage != 30 {
t.Errorf("heal amount = %d, want 30", ev.Damage)
}
}
}
}
if heals == 0 {
t.Error("heal item never triggered across 200 fights")
}
// Should trigger at most once per fight
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
count := 0
for _, ev := range r.Events {
if ev.Action == "heal_item" {
count++
}
}
if count > 1 {
t.Errorf("heal item triggered %d times in one fight, should be at most 1", count)
}
}
}
func TestSimulateCombat_WardAbsorb(t *testing.T) {
p := basePlayer()
p.Mods.WardCharges = 1
e := baseEnemy()
e.Stats.Attack = 30
wards := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "ward_absorb" {
wards++
if ev.Damage != 0 {
t.Error("ward absorb should deal 0 damage")
}
}
}
}
if wards == 0 {
t.Error("ward never activated across 100 fights")
}
}
func TestSimulateCombat_SporeCloud(t *testing.T) {
p := basePlayer()
p.Mods.SporeCloud = 10 // many rounds worth
e := baseEnemy()
e.Stats.Attack = 15
spores := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "spore_miss" {
spores++
}
}
}
if spores == 0 {
t.Error("spore cloud never caused a miss across 100 fights")
}
}
func TestSimulateCombat_ReflectDamage(t *testing.T) {
p := basePlayer()
p.Mods.ReflectNext = 0.5
e := baseEnemy()
e.Stats.Attack = 20
reflects := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "reflect_damage" {
reflects++
if ev.Damage <= 0 {
t.Error("reflected damage should be positive")
}
}
}
}
if reflects == 0 {
t.Error("reflect never triggered across 100 fights")
}
}
func TestSimulateCombat_AutoCritFirst(t *testing.T) {
p := basePlayer()
p.Mods.AutoCritFirst = true
p.Stats.CritRate = 0 // disable normal crits to isolate auto-crit
crits := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
for _, ev := range r.Events {
if ev.Actor == "player" && ev.Action == "crit" {
crits++
break // only count first per fight
}
}
}
if crits < 90 {
t.Errorf("auto-crit should fire on first player hit in nearly every fight, got %d/100", crits)
}
}
// ── Monster Ability Tests ────────────────────────────────────────────────────
func TestSimulateCombat_Poison(t *testing.T) {
p := basePlayer()
e := baseEnemy()
e.Ability = &MonsterAbility{
Name: "Venom Bite", Phase: "any", ProcChance: 1.0, Effect: "poison",
}
ticks := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "poison_tick" {
ticks++
}
}
}
if ticks == 0 {
t.Error("poison ticks never occurred with guaranteed proc")
}
}
func TestSimulateCombat_Enrage(t *testing.T) {
p := basePlayer()
p.Stats.Attack = 12
e := baseEnemy()
e.Stats.MaxHP = 50 // 40% threshold = 20 HP, reachable before death
e.Stats.Attack = 6 // weak so fights last long enough
e.Ability = &MonsterAbility{
Name: "Berserk", Phase: "any", ProcChance: 1.0, Effect: "enrage",
}
enrages := 0
for i := 0; i < 500; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "enrage" {
enrages++
break
}
}
}
if enrages == 0 {
t.Error("enrage never triggered across 500 fights")
}
}
func TestSimulateCombat_ArmorBreak(t *testing.T) {
p := basePlayer()
e := baseEnemy()
e.Ability = &MonsterAbility{
Name: "Rend", Phase: "opening", ProcChance: 1.0, Effect: "armor_break",
}
breaks := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "armor_break" {
breaks++
break
}
}
}
if breaks == 0 {
t.Error("armor break never triggered with guaranteed proc in opening")
}
}
func TestSimulateCombat_Stun(t *testing.T) {
p := basePlayer()
e := baseEnemy()
e.Ability = &MonsterAbility{
Name: "Bash", Phase: "any", ProcChance: 1.0, Effect: "stun",
}
stuns := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "stun" {
stuns++
break
}
}
}
if stuns == 0 {
t.Error("stun never triggered")
}
// Check that "stunned" event follows a "stun"
r := SimulateCombat(p, e, defaultCombatPhases)
for i, ev := range r.Events {
if ev.Action == "stun" {
for j := i + 1; j < len(r.Events); j++ {
if r.Events[j].Actor == "player" && r.Events[j].Action == "stunned" {
return // found it
}
if r.Events[j].Actor == "player" && (r.Events[j].Action == "hit" || r.Events[j].Action == "crit" || r.Events[j].Action == "miss") {
break // player acted before being stunned — could happen if stun fires and then round ends
}
}
}
}
}
func TestSimulateCombat_Lifesteal(t *testing.T) {
p := basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 100
e.Ability = &MonsterAbility{
Name: "Drain", Phase: "any", ProcChance: 1.0, Effect: "lifesteal",
}
steals := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "lifesteal" {
steals++
break
}
}
}
if steals == 0 {
t.Error("lifesteal never triggered")
}
}
func TestSimulateCombat_Cleave(t *testing.T) {
p := basePlayer()
e := baseEnemy()
e.Ability = &MonsterAbility{
Name: "Cleave", Phase: "clash", ProcChance: 1.0, Effect: "cleave",
}
cleaves := 0
for i := 0; i < 100; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
for _, ev := range r.Events {
if ev.Action == "cleave" {
cleaves++
}
}
}
// Should see pairs of cleave events (main + follow-up)
if cleaves == 0 {
t.Error("cleave never triggered")
}
if cleaves%2 != 0 {
// Not strictly required since fights can end mid-cleave, but check it's usually paired
t.Logf("cleave events = %d (odd count is possible if fight ends mid-cleave)", cleaves)
}
}
// ── Strong vs Weak ───────────────────────────────────────────────────────────
func TestSimulateCombat_StrongEnemyWinsMostly(t *testing.T) {
p := basePlayer()
p.Stats.MaxHP = 50
p.Stats.Attack = 8
e := baseEnemy()
e.Stats.MaxHP = 200
e.Stats.Attack = 30
e.Stats.Defense = 12
wins := 0
for i := 0; i < 1000; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
if r.PlayerWon {
wins++
}
}
if wins > 150 {
t.Errorf("player won %d/1000 against much stronger enemy — too high", wins)
}
}
func TestSimulateCombat_DungeonPhasesShorter(t *testing.T) {
totalDefault, totalDungeon := 0, 0
for i := 0; i < 500; i++ {
r1 := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
r2 := SimulateCombat(basePlayer(), baseEnemy(), dungeonCombatPhases)
totalDefault += r1.TotalRounds
totalDungeon += r2.TotalRounds
}
avgDefault := float64(totalDefault) / 500
avgDungeon := float64(totalDungeon) / 500
if avgDungeon >= avgDefault {
t.Errorf("dungeon phases should produce fewer rounds on average: dungeon=%.1f, default=%.1f", avgDungeon, avgDefault)
}
}
func TestSimulateCombat_ClosenessRange(t *testing.T) {
for i := 0; i < 500; i++ {
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
if r.Closeness < 0 || r.Closeness > 1.0 {
t.Errorf("closeness out of range: %f", r.Closeness)
}
}
}
func TestSimulateCombat_NearDeath(t *testing.T) {
// Enemy strong enough to bring player near death regularly
p := basePlayer()
p.Stats.MaxHP = 80
p.Stats.Attack = 12
e := baseEnemy()
e.Stats.MaxHP = 60
e.Stats.Attack = 18
nearDeaths := 0
for i := 0; i < 2000; i++ {
r := SimulateCombat(p, e, defaultCombatPhases)
if r.NearDeath {
nearDeaths++
}
}
if nearDeaths == 0 {
t.Error("near death never occurred in 2000 fights")
}
}