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 }