You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
529 lines
14 KiB
Go
529 lines
14 KiB
Go
package game
|
|
|
|
import (
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
"github.com/denisovdennis/autohero/internal/tuning"
|
|
)
|
|
|
|
const (
|
|
attackOutcomeHit = "hit"
|
|
attackOutcomeDodge = "dodge"
|
|
attackOutcomeBlock = "block"
|
|
attackOutcomeStun = "stun"
|
|
)
|
|
|
|
type DamageBreakdown struct {
|
|
RawDamage int
|
|
FinalDamage int
|
|
IsCrit bool
|
|
}
|
|
|
|
// CalculateDamage computes hero→enemy damage (combatDamageScale + combatDamageRoll*).
|
|
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) {
|
|
cfg := tuning.Get()
|
|
breakdown := calculateDamageBreakdown(baseAttack, defense, critChance, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax)
|
|
return breakdown.FinalDamage, breakdown.IsCrit
|
|
}
|
|
|
|
func calculateDamageBreakdown(baseAttack int, defense int, critChance float64, damageScale, rollMin, rollMax float64) DamageBreakdown {
|
|
atk := float64(baseAttack) * damageRollMultiplier(rollMin, rollMax)
|
|
|
|
// Defense reduces damage (simple formula: damage = atk - def, min 1).
|
|
dmg := atk - float64(defense)
|
|
if dmg < 1 {
|
|
dmg = 1
|
|
}
|
|
raw := int(dmg)
|
|
|
|
// Critical hit check.
|
|
isCrit := false
|
|
if critChance > 0 && rand.Float64() < critChance {
|
|
dmg *= 2
|
|
isCrit = true
|
|
}
|
|
|
|
dmg *= damageScale
|
|
if dmg < 1 {
|
|
dmg = 1
|
|
}
|
|
|
|
return DamageBreakdown{
|
|
RawDamage: raw,
|
|
FinalDamage: int(dmg),
|
|
IsCrit: isCrit,
|
|
}
|
|
}
|
|
|
|
func damageRollMultiplier(minRoll, maxRoll float64) float64 {
|
|
if minRoll <= 0 || maxRoll <= 0 {
|
|
return 1.0
|
|
}
|
|
if maxRoll < minRoll {
|
|
minRoll, maxRoll = maxRoll, minRoll
|
|
}
|
|
if maxRoll == minRoll {
|
|
return maxRoll
|
|
}
|
|
return minRoll + rand.Float64()*(maxRoll-minRoll)
|
|
}
|
|
|
|
// CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage.
|
|
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
|
|
dmg := float64(rawDamage)
|
|
|
|
for _, ab := range buffs {
|
|
if ab.IsExpired(now) {
|
|
continue
|
|
}
|
|
if ab.Buff.Type == model.BuffShield {
|
|
dmg *= (1 - ab.Buff.Magnitude)
|
|
}
|
|
}
|
|
for _, ad := range debuffs {
|
|
if ad.IsExpired(now) {
|
|
continue
|
|
}
|
|
if ad.Debuff.Type == model.DebuffWeaken {
|
|
dmg *= (1 - ad.Debuff.Magnitude)
|
|
}
|
|
}
|
|
|
|
if dmg < 1 {
|
|
dmg = 1
|
|
}
|
|
return int(dmg)
|
|
}
|
|
|
|
// ProcessAttack executes a single attack from hero to enemy and returns the combat event.
|
|
// It respects dodge ability on enemies and stun debuff on hero.
|
|
func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.CombatEvent {
|
|
// If hero is stunned, skip attack entirely.
|
|
if hero.IsStunned(now) {
|
|
return model.CombatEvent{
|
|
Type: "attack",
|
|
HeroID: hero.ID,
|
|
Damage: 0,
|
|
Source: "hero",
|
|
Outcome: attackOutcomeStun,
|
|
HeroHP: hero.HP,
|
|
EnemyHP: enemy.HP,
|
|
Timestamp: now,
|
|
}
|
|
}
|
|
|
|
critChance := heroCritChance(hero, now)
|
|
|
|
// Check enemy dodge ability.
|
|
if enemy.HasAbility(model.AbilityDodge) {
|
|
if rand.Float64() < tuning.Get().EnemyDodgeChance {
|
|
return model.CombatEvent{
|
|
Type: "attack",
|
|
HeroID: hero.ID,
|
|
Damage: 0,
|
|
Source: "hero",
|
|
Outcome: attackOutcomeDodge,
|
|
HeroHP: hero.HP,
|
|
EnemyHP: enemy.HP,
|
|
Timestamp: now,
|
|
}
|
|
}
|
|
}
|
|
|
|
dmg, isCrit := CalculateDamage(hero.EffectiveAttackAt(now), enemy.Defense, critChance)
|
|
enemy.HP -= dmg
|
|
if enemy.HP < 0 {
|
|
enemy.HP = 0
|
|
}
|
|
|
|
return model.CombatEvent{
|
|
Type: "attack",
|
|
HeroID: hero.ID,
|
|
Damage: dmg,
|
|
Source: "hero",
|
|
Outcome: attackOutcomeHit,
|
|
IsCrit: isCrit,
|
|
HeroHP: hero.HP,
|
|
EnemyHP: enemy.HP,
|
|
Timestamp: now,
|
|
}
|
|
}
|
|
|
|
// EnemyAttackDamageMultiplier returns a damage multiplier based on the enemy's
|
|
// attack counter and burst/chain abilities. It increments AttackCount.
|
|
func EnemyAttackDamageMultiplier(enemy *model.Enemy) float64 {
|
|
enemy.AttackCount++
|
|
mult := 1.0
|
|
cfg := tuning.Get()
|
|
|
|
// Orc Warrior: every 3rd attack deals 1.5x damage (spec §4.1).
|
|
if enemy.HasAbility(model.AbilityBurst) && cfg.EnemyBurstEveryN > 0 && enemy.AttackCount%int(cfg.EnemyBurstEveryN) == 0 {
|
|
mult *= cfg.EnemyBurstMultiplier
|
|
}
|
|
|
|
// Lightning Titan: after 5 attacks, next attack deals 3x damage (spec §4.2).
|
|
if enemy.HasAbility(model.AbilityChainLightning) && cfg.EnemyChainEveryN > 0 && enemy.AttackCount%int(cfg.EnemyChainEveryN) == 0 {
|
|
mult *= cfg.EnemyChainMultiplier
|
|
}
|
|
|
|
return mult
|
|
}
|
|
|
|
// ProcessEnemyAttack executes a single attack from enemy to hero, including
|
|
// debuff application and burst/chain abilities based on the enemy's type.
|
|
func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.CombatEvent {
|
|
critChance := enemy.CritChance
|
|
if enemy.HasAbility(model.AbilityCritical) && critChance < tuning.Get().EnemyCriticalMinChance {
|
|
critChance = tuning.Get().EnemyCriticalMinChance
|
|
}
|
|
critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap)
|
|
|
|
cfg := tuning.Get()
|
|
scale := cfg.EnemyCombatDamageScale
|
|
if scale <= 0 {
|
|
scale = tuning.DefaultEnemyCombatDamageScale
|
|
}
|
|
rollMin := cfg.EnemyCombatDamageRollMin
|
|
rollMax := cfg.EnemyCombatDamageRollMax
|
|
if rollMin <= 0 || rollMax <= 0 {
|
|
rollMin = tuning.DefaultEnemyCombatDamageRollMin
|
|
rollMax = tuning.DefaultEnemyCombatDamageRollMax
|
|
}
|
|
breakdown := calculateDamageBreakdown(enemy.Attack, hero.EffectiveDefenseAt(now), critChance, scale, rollMin, rollMax)
|
|
rawDmg, isCrit := breakdown.FinalDamage, breakdown.IsCrit
|
|
|
|
// Apply burst/chain ability multiplier.
|
|
burstMult := EnemyAttackDamageMultiplier(enemy)
|
|
if burstMult > 1.0 {
|
|
rawDmg = int(float64(rawDmg) * burstMult)
|
|
}
|
|
|
|
if rand.Float64() < hero.EffectiveBlockChance(now) {
|
|
return model.CombatEvent{
|
|
Type: "attack",
|
|
HeroID: hero.ID,
|
|
Damage: 0,
|
|
Source: "enemy",
|
|
Outcome: attackOutcomeBlock,
|
|
HeroHP: hero.HP,
|
|
EnemyHP: enemy.HP,
|
|
Timestamp: now,
|
|
}
|
|
}
|
|
|
|
dmg := CalculateIncomingDamage(rawDmg, hero.Buffs, hero.Debuffs, now)
|
|
|
|
hero.HP -= dmg
|
|
if hero.HP < 0 {
|
|
hero.HP = 0
|
|
}
|
|
|
|
debuffApplied := tryApplyDebuff(hero, enemy, now)
|
|
|
|
return model.CombatEvent{
|
|
Type: "attack",
|
|
HeroID: hero.ID,
|
|
Damage: dmg,
|
|
Source: "enemy",
|
|
Outcome: attackOutcomeHit,
|
|
IsCrit: isCrit,
|
|
DebuffApplied: debuffApplied,
|
|
HeroHP: hero.HP,
|
|
EnemyHP: enemy.HP,
|
|
Timestamp: now,
|
|
}
|
|
}
|
|
|
|
func heroCritChance(hero *model.Hero, now time.Time) float64 {
|
|
_ = now
|
|
critChance := 0.0
|
|
if weapon := hero.Gear[model.SlotMainHand]; weapon != nil {
|
|
critChance = weapon.CritChance
|
|
}
|
|
return capChance(critChance, tuning.Get().HeroCritChanceCap)
|
|
}
|
|
|
|
func capChance(chance float64, cap float64) float64 {
|
|
if cap > 0 && chance > cap {
|
|
return cap
|
|
}
|
|
if chance < 0 {
|
|
return 0
|
|
}
|
|
return chance
|
|
}
|
|
|
|
// tryApplyDebuff checks enemy abilities and rolls to apply debuffs to the hero.
|
|
// Returns the debuff type string if one was applied, empty string otherwise.
|
|
func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string {
|
|
type debuffRule struct {
|
|
ability model.SpecialAbility
|
|
debuff model.DebuffType
|
|
chance float64
|
|
}
|
|
|
|
rules := []debuffRule{
|
|
{model.AbilityBurn, model.DebuffBurn, tuning.Get().DebuffProcBurn},
|
|
{model.AbilityPoison, model.DebuffPoison, tuning.Get().DebuffProcPoison},
|
|
{model.AbilitySlow, model.DebuffSlow, tuning.Get().DebuffProcSlow},
|
|
{model.AbilityStun, model.DebuffStun, tuning.Get().DebuffProcStun},
|
|
{model.AbilityFreeze, model.DebuffFreeze, tuning.Get().DebuffProcFreeze},
|
|
{model.AbilityIceSlow, model.DebuffIceSlow, tuning.Get().DebuffProcIceSlow},
|
|
}
|
|
|
|
for _, rule := range rules {
|
|
if !enemy.HasAbility(rule.ability) {
|
|
continue
|
|
}
|
|
if rand.Float64() >= rule.chance {
|
|
continue
|
|
}
|
|
|
|
ApplyDebuff(hero, rule.debuff, now)
|
|
return string(rule.debuff)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ApplyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes.
|
|
func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
|
|
def, ok := model.DebuffDefinition(debuffType)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Refresh if already active.
|
|
for i, ad := range hero.Debuffs {
|
|
if ad.Debuff.Type == debuffType && !ad.IsExpired(now) {
|
|
hero.Debuffs[i].AppliedAt = now
|
|
hero.Debuffs[i].ExpiresAt = now.Add(def.Duration)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Remove expired debuffs.
|
|
active := hero.Debuffs[:0]
|
|
for _, ad := range hero.Debuffs {
|
|
if !ad.IsExpired(now) {
|
|
active = append(active, ad)
|
|
}
|
|
}
|
|
|
|
ad := model.ActiveDebuff{
|
|
Debuff: def,
|
|
AppliedAt: now,
|
|
ExpiresAt: now.Add(def.Duration),
|
|
}
|
|
hero.Debuffs = append(active, ad)
|
|
}
|
|
|
|
// ProcessDebuffDamage applies periodic damage from active debuffs (poison, burn).
|
|
// Should be called each combat tick. Returns total damage dealt by debuffs this tick.
|
|
func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int {
|
|
totalDmg := 0
|
|
|
|
for i := range hero.Debuffs {
|
|
ad := &hero.Debuffs[i]
|
|
if ad.IsExpired(now) {
|
|
continue
|
|
}
|
|
|
|
switch ad.Debuff.Type {
|
|
case model.DebuffPoison:
|
|
// % max HP per second, scaled by tick duration; fractional damage carries over ticks.
|
|
dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder
|
|
dmg := int(dmgFloat)
|
|
ad.DotRemainder = dmgFloat - float64(dmg)
|
|
if dmg > 0 {
|
|
hero.HP -= dmg
|
|
totalDmg += dmg
|
|
}
|
|
|
|
case model.DebuffBurn:
|
|
dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder
|
|
dmg := int(dmgFloat)
|
|
ad.DotRemainder = dmgFloat - float64(dmg)
|
|
if dmg > 0 {
|
|
hero.HP -= dmg
|
|
totalDmg += dmg
|
|
}
|
|
}
|
|
}
|
|
|
|
if hero.HP < 0 {
|
|
hero.HP = 0
|
|
}
|
|
|
|
return totalDmg
|
|
}
|
|
|
|
// ProcessEnemyRegen handles HP regeneration for enemies with the regen ability.
|
|
// Should be called each combat tick. Uses remainder to avoid per-tick rounding spikes.
|
|
func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration, remainder *float64) int {
|
|
if !enemy.HasAbility(model.AbilityRegen) {
|
|
return 0
|
|
}
|
|
|
|
// Regen rates: runtime_config JSON merged at startup; Effective* falls back to tuning.DefaultEnemyRegen*.
|
|
var regenRate float64
|
|
switch enemy.Type {
|
|
case model.EnemySkeletonKing:
|
|
regenRate = tuning.EffectiveEnemyRegenSkeletonKing()
|
|
case model.EnemyForestWarden:
|
|
regenRate = tuning.EffectiveEnemyRegenForestWarden()
|
|
case model.EnemyBattleLizard:
|
|
regenRate = tuning.EffectiveEnemyRegenBattleLizard()
|
|
default:
|
|
regenRate = tuning.EffectiveEnemyRegenDefault()
|
|
}
|
|
|
|
healFloat := float64(enemy.MaxHP) * regenRate * tickDuration.Seconds()
|
|
if remainder != nil {
|
|
healFloat += *remainder
|
|
}
|
|
healed := int(healFloat)
|
|
if remainder != nil {
|
|
*remainder = healFloat - float64(healed)
|
|
}
|
|
if healed <= 0 {
|
|
return 0
|
|
}
|
|
before := enemy.HP
|
|
enemy.HP += healed
|
|
if enemy.HP > enemy.MaxHP {
|
|
enemy.HP = enemy.MaxHP
|
|
}
|
|
|
|
if enemy.HP <= before {
|
|
return 0
|
|
}
|
|
return enemy.HP - before
|
|
}
|
|
|
|
// CheckDeath checks if the hero is dead and attempts resurrection if a buff is active.
|
|
// Returns true if the hero is dead (no resurrection available).
|
|
func CheckDeath(hero *model.Hero, now time.Time) bool {
|
|
if hero.IsAlive() {
|
|
return false
|
|
}
|
|
|
|
// Check for resurrection buff.
|
|
for i, ab := range hero.Buffs {
|
|
if ab.IsExpired(now) {
|
|
continue
|
|
}
|
|
if ab.Buff.Type == model.BuffResurrection {
|
|
// Revive with magnitude % of max HP.
|
|
hero.HP = int(float64(hero.MaxHP) * ab.Buff.Magnitude)
|
|
if hero.HP < 1 {
|
|
hero.HP = 1
|
|
}
|
|
// Consume the buff by expiring it immediately.
|
|
hero.Buffs[i].ExpiresAt = now
|
|
return false
|
|
}
|
|
}
|
|
|
|
hero.State = model.StateDead
|
|
return true
|
|
}
|
|
|
|
// ApplyBuff adds a buff to the hero. If the same buff type is already active, it refreshes.
|
|
func ApplyBuff(hero *model.Hero, buffType model.BuffType, now time.Time) *model.ActiveBuff {
|
|
def, ok := model.BuffDefinition(buffType)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Heal buff is applied instantly.
|
|
if buffType == model.BuffHeal {
|
|
healAmount := int(float64(hero.MaxHP) * def.Magnitude)
|
|
hero.HP += healAmount
|
|
if hero.HP > hero.MaxHP {
|
|
hero.HP = hero.MaxHP
|
|
}
|
|
}
|
|
|
|
// Check if already active and refresh.
|
|
for i, ab := range hero.Buffs {
|
|
if ab.Buff.Type == buffType && !ab.IsExpired(now) {
|
|
hero.Buffs[i].AppliedAt = now
|
|
hero.Buffs[i].ExpiresAt = now.Add(def.Duration)
|
|
return &hero.Buffs[i]
|
|
}
|
|
}
|
|
|
|
// Remove expired buffs while we're here.
|
|
active := hero.Buffs[:0]
|
|
for _, ab := range hero.Buffs {
|
|
if !ab.IsExpired(now) {
|
|
active = append(active, ab)
|
|
}
|
|
}
|
|
|
|
ab := model.ActiveBuff{
|
|
Buff: def,
|
|
AppliedAt: now,
|
|
ExpiresAt: now.Add(def.Duration),
|
|
}
|
|
hero.Buffs = append(active, ab)
|
|
return &ab
|
|
}
|
|
|
|
// HasLuckBuff returns true if the hero has an active luck buff.
|
|
func HasLuckBuff(hero *model.Hero, now time.Time) bool {
|
|
for _, ab := range hero.Buffs {
|
|
if ab.Buff.Type == model.BuffLuck && !ab.IsExpired(now) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LuckMultiplier returns the loot multiplier when the Luck buff is active (tuning.LuckBuffMultiplier).
|
|
func LuckMultiplier(hero *model.Hero, now time.Time) float64 {
|
|
if HasLuckBuff(hero, now) {
|
|
return tuning.Get().LuckBuffMultiplier
|
|
}
|
|
return 1.0
|
|
}
|
|
|
|
// ProcessSummonDamage applies bonus damage from summoned minions (Skeleton King).
|
|
// MVP: minions are modeled as periodic bonus damage (25% of Skeleton King's attack)
|
|
// applied every tick, rather than as full entity spawns.
|
|
// The spec says summons appear every 15 seconds; we approximate by checking
|
|
// the combat duration and applying minion damage when a 15s boundary is crossed.
|
|
func ProcessSummonDamage(hero *model.Hero, enemy *model.Enemy, combatStart time.Time, lastTick time.Time, now time.Time) int {
|
|
if !enemy.HasAbility(model.AbilitySummon) {
|
|
return 0
|
|
}
|
|
|
|
dv := tuning.DefaultValues()
|
|
// How many summon cycles have elapsed since combat start.
|
|
cycleSec := tuning.Get().SummonCycleSeconds
|
|
if cycleSec < 1 {
|
|
cycleSec = dv.SummonCycleSeconds
|
|
}
|
|
prevCycles := int(lastTick.Sub(combatStart).Seconds()) / int(cycleSec)
|
|
currCycles := int(now.Sub(combatStart).Seconds()) / int(cycleSec)
|
|
|
|
if currCycles <= prevCycles {
|
|
return 0
|
|
}
|
|
|
|
// Each summon wave deals (1/divisor) of the enemy's base attack as minion damage.
|
|
div := tuning.Get().SummonDamageDivisor
|
|
if div < 1 {
|
|
div = dv.SummonDamageDivisor
|
|
}
|
|
minionDmg := max(1, enemy.Attack/int(div))
|
|
hero.HP -= minionDmg
|
|
if hero.HP < 0 {
|
|
hero.HP = 0
|
|
}
|
|
return minionDmg
|
|
}
|