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>
277 lines
8.1 KiB
Go
277 lines
8.1 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCheckDeathSaveEvent_Found(t *testing.T) {
|
|
events := []CombatEvent{
|
|
{Action: "hit"}, {Action: "crit"}, {Action: "death_save"}, {Action: "hit"},
|
|
}
|
|
if !checkDeathSaveEvent(events) {
|
|
t.Error("should find death_save event")
|
|
}
|
|
}
|
|
|
|
func TestCheckDeathSaveEvent_NotFound(t *testing.T) {
|
|
events := []CombatEvent{
|
|
{Action: "hit"}, {Action: "crit"}, {Action: "block"},
|
|
}
|
|
if checkDeathSaveEvent(events) {
|
|
t.Error("should not find death_save event")
|
|
}
|
|
}
|
|
|
|
func TestCheckDeathSaveEvent_Empty(t *testing.T) {
|
|
if checkDeathSaveEvent(nil) {
|
|
t.Error("nil events should return false")
|
|
}
|
|
if checkDeathSaveEvent([]CombatEvent{}) {
|
|
t.Error("empty events should return false")
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_AllNeutral(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 1.0})
|
|
if r.Total != 100 {
|
|
t.Errorf("neutral bonuses: got %d, want 100", r.Total)
|
|
}
|
|
if r.Breakdown != "" {
|
|
t.Errorf("neutral breakdown should be empty, got %q", r.Breakdown)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_NearDeath(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 100, NearDeath: true, OverlevelMult: 1.0})
|
|
if r.Total != 114 {
|
|
t.Errorf("near-death: got %d, want 114", r.Total)
|
|
}
|
|
if r.Breakdown != "+15% near-death" {
|
|
t.Errorf("breakdown = %q", r.Breakdown)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_BonusMult(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 100, BonusMult: 20, OverlevelMult: 1.0})
|
|
if r.Total != 120 {
|
|
t.Errorf("bonus mult 20%%: got %d, want 120", r.Total)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_Ironclad(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 100, Ironclad: true, OverlevelMult: 1.0})
|
|
if r.Total != 105 {
|
|
t.Errorf("ironclad: got %d, want 105", r.Total)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_OverlevelPenalty(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 0.5})
|
|
if r.Total != 50 {
|
|
t.Errorf("overlevel 0.5x: got %d, want 50", r.Total)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_OverlevelFloor(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{BaseXP: 1, OverlevelMult: 0.01})
|
|
if r.Total < 1 {
|
|
t.Errorf("overlevel floor: got %d, want >= 1", r.Total)
|
|
}
|
|
}
|
|
|
|
func TestApplyXPBonuses_AllStacked(t *testing.T) {
|
|
r := applyXPBonuses(XPBonusParams{
|
|
BaseXP: 100, NearDeath: true, BonusMult: 20, Ironclad: true, OverlevelMult: 0.8,
|
|
})
|
|
if r.Total != 113 {
|
|
t.Errorf("all stacked: got %d, want 113", r.Total)
|
|
}
|
|
if r.Breakdown == "" {
|
|
t.Error("stacked breakdown should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_PardonFires(t *testing.T) {
|
|
_ = transitionDeath(DeathTransitionParams{
|
|
Char: &AdventureCharacter{Alive: true},
|
|
ChatLevel: 25,
|
|
Location: "Dark Cave",
|
|
AllowPardon: true,
|
|
})
|
|
// Pardon is 33% chance — run enough times to confirm it can fire
|
|
pardoned := 0
|
|
for i := 0; i < 300; i++ {
|
|
c := &AdventureCharacter{Alive: true}
|
|
res := transitionDeath(DeathTransitionParams{
|
|
Char: c, ChatLevel: 25, Location: "Dark Cave", AllowPardon: true,
|
|
})
|
|
if res.Pardoned {
|
|
pardoned++
|
|
if c.LastPardonUsed == nil {
|
|
t.Fatal("pardon should set LastPardonUsed")
|
|
}
|
|
if c.GrudgeLocation != "Dark Cave" {
|
|
t.Fatalf("pardon should set GrudgeLocation, got %q", c.GrudgeLocation)
|
|
}
|
|
if res.Died || res.Reprieved {
|
|
t.Fatal("pardoned should not also be died/reprieved")
|
|
}
|
|
}
|
|
}
|
|
if pardoned == 0 {
|
|
t.Error("pardon should fire at least once in 300 trials (p≈33%)")
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_PardonCooldown(t *testing.T) {
|
|
recent := time.Now().UTC().Add(-1 * time.Hour)
|
|
char := &AdventureCharacter{Alive: true, LastPardonUsed: &recent}
|
|
r := transitionDeath(DeathTransitionParams{
|
|
Char: char, ChatLevel: 25, Location: "Cave", AllowPardon: true,
|
|
})
|
|
if r.Pardoned {
|
|
t.Error("pardon should not fire during cooldown")
|
|
}
|
|
if !r.Died {
|
|
t.Error("should die when pardon on cooldown and no sovereign")
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_SovereignReprieve(t *testing.T) {
|
|
equip := map[EquipmentSlot]*AdvEquipment{
|
|
SlotWeapon: {Condition: 80, ArenaSet: "sovereign"},
|
|
SlotArmor: {Condition: 90, ArenaSet: "sovereign"},
|
|
SlotHelmet: {Condition: 70, ArenaSet: "sovereign"},
|
|
SlotBoots: {Condition: 60, ArenaSet: "sovereign"},
|
|
SlotTool: {Condition: 50, ArenaSet: "sovereign"},
|
|
}
|
|
char := &AdventureCharacter{Alive: true}
|
|
r := transitionDeath(DeathTransitionParams{
|
|
Char: char, Equip: equip, Location: "Abyss",
|
|
AllowSovereign: true,
|
|
})
|
|
if !r.Reprieved {
|
|
t.Fatal("sovereign should reprieve")
|
|
}
|
|
if r.Died || r.Pardoned {
|
|
t.Error("reprieved should not also be died/pardoned")
|
|
}
|
|
if char.GrudgeLocation != "Abyss" {
|
|
t.Errorf("grudge location = %q, want Abyss", char.GrudgeLocation)
|
|
}
|
|
for slot, eq := range equip {
|
|
if eq.Condition != 1 {
|
|
t.Errorf("slot %s condition = %d, want 1", slot, eq.Condition)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_KillAndPetRecovery(t *testing.T) {
|
|
deaths, petRecoveries := 0, 0
|
|
for i := 0; i < 500; i++ {
|
|
char := &AdventureCharacter{
|
|
Alive: true, PetType: "cat", PetArrived: true, PetLevel: 5,
|
|
}
|
|
r := transitionDeath(DeathTransitionParams{Char: char, Location: "Mine"})
|
|
if !r.Died {
|
|
t.Fatal("should die with no saves available")
|
|
}
|
|
if !char.Alive == false {
|
|
t.Fatal("character should not be alive")
|
|
}
|
|
if char.GrudgeLocation != "Mine" {
|
|
t.Fatalf("grudge = %q, want Mine", char.GrudgeLocation)
|
|
}
|
|
deaths++
|
|
if r.PetRecovered {
|
|
petRecoveries++
|
|
if char.DeadUntil == nil {
|
|
t.Fatal("dead until should be set")
|
|
}
|
|
}
|
|
}
|
|
if petRecoveries == 0 {
|
|
t.Error("pet recovery should fire at least once in 500 deaths")
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_EngineSaveBurnsCooldown(t *testing.T) {
|
|
char := &AdventureCharacter{Alive: true}
|
|
r := transitionDeath(DeathTransitionParams{
|
|
Char: char, EngineSaved: true,
|
|
})
|
|
if !r.Died {
|
|
t.Error("should die (no sovereign/pardon)")
|
|
}
|
|
if char.DeathReprieveLast == nil {
|
|
t.Error("engine save should burn DeathReprieveLast")
|
|
}
|
|
}
|
|
|
|
func TestTransitionDeath_NoLocation(t *testing.T) {
|
|
char := &AdventureCharacter{Alive: true, GrudgeLocation: "old"}
|
|
transitionDeath(DeathTransitionParams{Char: char})
|
|
if char.GrudgeLocation != "old" {
|
|
t.Errorf("empty location should not change grudge, got %q", char.GrudgeLocation)
|
|
}
|
|
}
|
|
|
|
func TestApplyDegradationModifiers_Basic(t *testing.T) {
|
|
equip := map[EquipmentSlot]*AdvEquipment{
|
|
SlotWeapon: {Condition: 100},
|
|
SlotArmor: {Condition: 100},
|
|
}
|
|
damage := map[EquipmentSlot]int{
|
|
SlotWeapon: 10,
|
|
SlotArmor: 20,
|
|
}
|
|
result := applyDegradationModifiers(damage, equip)
|
|
if equip[SlotWeapon].Condition != 90 {
|
|
t.Errorf("weapon condition = %d, want 90", equip[SlotWeapon].Condition)
|
|
}
|
|
if equip[SlotArmor].Condition != 80 {
|
|
t.Errorf("armor condition = %d, want 80", equip[SlotArmor].Condition)
|
|
}
|
|
if result[SlotWeapon] != 10 || result[SlotArmor] != 20 {
|
|
t.Errorf("returned damage should match applied: weapon=%d, armor=%d", result[SlotWeapon], result[SlotArmor])
|
|
}
|
|
}
|
|
|
|
func TestApplyDegradationModifiers_Tempered(t *testing.T) {
|
|
equip := map[EquipmentSlot]*AdvEquipment{
|
|
SlotWeapon: {Condition: 100, ArenaSet: "tempered"},
|
|
SlotArmor: {Condition: 100, ArenaSet: "tempered"},
|
|
SlotHelmet: {Condition: 100, ArenaSet: "tempered"},
|
|
SlotBoots: {Condition: 100, ArenaSet: "tempered"},
|
|
SlotTool: {Condition: 100, ArenaSet: "tempered"},
|
|
}
|
|
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
|
result := applyDegradationModifiers(damage, equip)
|
|
if result[SlotWeapon] != 15 { // 20 * 0.75 = 15
|
|
t.Errorf("tempered weapon damage = %d, want 15", result[SlotWeapon])
|
|
}
|
|
}
|
|
|
|
func TestApplyDegradationModifiers_Mastery(t *testing.T) {
|
|
equip := map[EquipmentSlot]*AdvEquipment{
|
|
SlotWeapon: {Condition: 100, ActionsUsed: 25},
|
|
}
|
|
damage := map[EquipmentSlot]int{SlotWeapon: 10}
|
|
result := applyDegradationModifiers(damage, equip)
|
|
if result[SlotWeapon] != 8 { // 10 * 0.8 = 8
|
|
t.Errorf("mastery weapon damage = %d, want 8", result[SlotWeapon])
|
|
}
|
|
}
|
|
|
|
func TestApplyDegradationModifiers_ConditionFloor(t *testing.T) {
|
|
equip := map[EquipmentSlot]*AdvEquipment{
|
|
SlotWeapon: {Condition: 5},
|
|
}
|
|
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
|
applyDegradationModifiers(damage, equip)
|
|
if equip[SlotWeapon].Condition != 0 {
|
|
t.Errorf("condition should floor at 0, got %d", equip[SlotWeapon].Condition)
|
|
}
|
|
}
|