mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
580 lines
14 KiB
Go
580 lines
14 KiB
Go
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")
|
|
}
|
|
}
|