Files
gogobee/internal/plugin/combat_stats.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

232 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"math/rand/v2"
"time"
)
// DerivePlayerStats converts game-layer objects into the combat engine's stat model.
func DerivePlayerStats(
char *AdventureCharacter,
equip map[EquipmentSlot]*AdvEquipment,
bonuses *AdvBonusSummary,
chatLevel int,
streak int,
hasGrudge bool,
) (CombatStats, CombatModifiers) {
arenaSets := advEquippedArenaSets(equip)
// Stat baselines. CombatLevel is dead — player power keys off gear here
// and dnd_level / ability scores via applyDnDPlayerLayer afterwards.
// MaxHP is computed below only so the armor% / arena% / housing% bonus
// formulas have something to scale against; the base is subtracted out
// when we capture stats.HPBonus, so it never reaches combat.
const legacyBase = 50
stats := CombatStats{
MaxHP: legacyBase,
Attack: 5 + bonuses.CombatBonus,
Defense: 3 + bonuses.CombatBonus/2,
Speed: 5,
CritRate: 0.03,
DodgeRate: 0.02,
BlockRate: 0.02,
}
// Equipment contributions
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok || eq == nil {
continue
}
eTier := advEffectiveTier(eq)
cond := 0.3 + 0.7*(float64(eq.Condition)/100.0) // smooth degradation curve
effective := eTier * cond
switch slot {
case SlotWeapon:
stats.Attack += int(effective * 2)
stats.CritRate += eTier * 0.005
case SlotArmor:
stats.Defense += int(effective * 1.5)
stats.MaxHP += int(float64(stats.MaxHP) * eTier * 0.03)
case SlotHelmet:
stats.Defense += int(effective * 0.5)
stats.DodgeRate += eTier * 0.01
case SlotBoots:
stats.Speed += int(effective)
stats.DodgeRate += eTier * 0.005
case SlotTool:
stats.Attack += int(effective * 0.5)
stats.BlockRate += eTier * 0.01
}
}
// Arena set bonuses
if arenaSets["champions"] {
stats.MaxHP = int(float64(stats.MaxHP) * 1.10)
stats.Attack = int(float64(stats.Attack) * 1.10)
stats.Defense = int(float64(stats.Defense) * 1.10)
stats.Speed = int(float64(stats.Speed) * 1.10)
}
if arenaSets["bloodied"] {
stats.CritRate += 0.03
}
if arenaSets["ironclad"] {
stats.MaxHP = int(float64(stats.MaxHP) * 1.05)
}
// Sovereign: handled via DeathSave modifier
// Tempered: handled post-combat in degradation
// Housing HP bonus
stats.MaxHP += int(float64(stats.MaxHP) * char.HouseHPBonus())
// Capture the equipment/arena/housing HP delta as fight-only headroom.
// applyDnDPlayerLayer adds this to c.HPMax to form combat MaxHP — gear
// power preserved, dnd_character.hp_current is the canonical wound store
// with no scale conversion. The legacy 50+CL*2 base is intentionally
// dropped; monster damage is tuned to the dnd HP scale.
stats.HPBonus = stats.MaxHP - legacyBase
// Streak bonuses
switch {
case streak >= 30:
stats.Attack = int(float64(stats.Attack) * 1.20)
stats.Defense = int(float64(stats.Defense) * 1.15)
case streak >= 14:
stats.Attack = int(float64(stats.Attack) * 1.15)
stats.Defense = int(float64(stats.Defense) * 1.10)
case streak >= 7:
stats.Attack = int(float64(stats.Attack) * 1.10)
stats.Defense = int(float64(stats.Defense) * 1.05)
case streak >= 3:
stats.Attack = int(float64(stats.Attack) * 1.05)
}
// Grudge bonus
if hasGrudge {
stats.Attack = int(float64(stats.Attack) * 1.10)
}
// Treasure bonuses mapped to stats
if bonuses.DeathModifier < 0 {
stats.Defense += int(-bonuses.DeathModifier * 2)
}
if bonuses.SuccessBonus > 0 {
stats.Attack += int(bonuses.SuccessBonus * 0.5)
}
if bonuses.ExceptionalBonus > 0 {
stats.CritRate += bonuses.ExceptionalBonus / 100.0
}
// Chat level perks
chatTier := chatLevel / 10
if chatTier > 5 {
chatTier = 5
}
stats.CritRate += float64(chatTier) * 0.005
// Cap rates
if stats.CritRate > 0.50 {
stats.CritRate = 0.50
}
if stats.DodgeRate > 0.40 {
stats.DodgeRate = 0.40
}
if stats.BlockRate > 0.40 {
stats.BlockRate = 0.40
}
// Modifiers
mods := CombatModifiers{
DamageBonus: 0,
DamageReduct: 1.0,
}
// Streak damage reduction
switch {
case streak >= 30:
mods.DamageReduct = 0.95
case streak >= 14:
mods.DamageReduct = 0.97
}
// Sovereign death save
if arenaSets["sovereign"] {
if char.DeathReprieveLast == nil || time.Since(*char.DeathReprieveLast) >= 168*time.Hour {
mods.DeathSave = true
}
}
// Pet modifiers
if char.HasPet() {
mods.PetAttackProc = petAttackChance(char.PetLevel)
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
}
if char.PetMorningDefense {
mods.DamageReduct *= 0.95 // 5% less damage
}
// NPC debuffs
now := time.Now().UTC()
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
mods.CrowdRevengeProc = 0.20
mods.CrowdRevengeDmg = 3 + rand.IntN(6) // 3-8 damage
}
// NPC buffs
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
mods.MistyHealProc = 0.20
mods.MistyHealAmt = 8 + char.CombatLevel/5 + rand.IntN(5)
}
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
mods.SniperKillProc = 0.08
}
return stats, mods
}
// DeriveArenaMonsterStats converts an ArenaMonster to combat engine stats.
// Arena monsters face players with high combat level and arena-tier gear,
// so stats must scale hard with ThreatLevel.
func DeriveArenaMonsterStats(monster *ArenaMonster) (CombatStats, CombatModifiers) {
tl := float64(monster.ThreatLevel)
bl := monster.BaseLethality
stats := CombatStats{
MaxHP: 40 + int(tl*4+bl*60),
Attack: 8 + int(bl*40) + int(tl*0.8),
Defense: 3 + int(tl*0.5+bl*10),
Speed: 5 + int(tl*0.3),
CritRate: bl * 0.20,
DodgeRate: 0.02 + tl*0.003,
BlockRate: 0.01 + bl*0.03,
}
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
return stats, mods
}
// DeriveDungeonMonsterStats synthesizes monster stats from a dungeon location.
// Tuned to the dnd HP scale (post HP-unification): well-equipped players at
// the location's MinLevel should win the vast majority of fights, with deaths
// coming from bad luck (crits, hazards, initiative). Old quadratic Attack
// formula assumed legacy ~100 HP players; the new linear scaling matches the
// ~1280 HP range of dnd-scale fighters.
func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) {
t := float64(loc.Tier)
death := loc.BaseDeathPct // 8 to 60
stats := CombatStats{
MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget
Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 1380 HP, not 100+)
Defense: 2 + int(t*1.2),
Speed: 4 + int(t*1.5),
CritRate: 0.03 + death*0.003,
DodgeRate: 0.02 + t*0.008,
BlockRate: 0.01 + t*0.005,
}
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
return stats, mods
}