mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
579
internal/plugin/combat_engine_test.go
Normal file
579
internal/plugin/combat_engine_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user