package game import ( "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" ) // combatDamageScale stretches fights (MVP tuning; paired with slower attack cadence in engine). const combatDamageScale = 0.35 // CalculateDamage computes the final damage dealt from attacker stats to a defender, // applying defense and critical hits. func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) { atk := float64(baseAttack) // Defense reduces damage (simple formula: damage = atk - def, min 1). dmg := atk - float64(defense) if dmg < 1 { dmg = 1 } // Critical hit check. if critChance > 0 && rand.Float64() < critChance { dmg *= 2 isCrit = true } dmg *= combatDamageScale if dmg < 1 { dmg = 1 } return int(dmg), isCrit } // CalculateIncomingDamage applies shield buff reduction to incoming damage. func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, 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) } } 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", HeroHP: hero.HP, EnemyHP: enemy.HP, Timestamp: now, } } critChance := 0.0 if weapon := hero.Gear[model.SlotMainHand]; weapon != nil { critChance = weapon.CritChance } // Check enemy dodge ability. if enemy.HasAbility(model.AbilityDodge) { if rand.Float64() < 0.20 { // 20% dodge chance return model.CombatEvent{ Type: "attack", HeroID: hero.ID, Damage: 0, Source: "hero", 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", 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 // Orc Warrior: every 3rd attack deals 1.5x damage (spec §4.1). if enemy.HasAbility(model.AbilityBurst) && enemy.AttackCount%3 == 0 { mult *= 1.5 } // Lightning Titan: after 5 attacks, next attack deals 3x damage (spec §4.2). if enemy.HasAbility(model.AbilityChainLightning) && enemy.AttackCount%6 == 0 { mult *= 3.0 } 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 < 0.15 { critChance = 0.15 } rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance) // Apply burst/chain ability multiplier. burstMult := EnemyAttackDamageMultiplier(enemy) if burstMult > 1.0 { rawDmg = int(float64(rawDmg) * burstMult) } dmg := CalculateIncomingDamage(rawDmg, hero.Buffs, 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", IsCrit: isCrit, DebuffApplied: debuffApplied, HeroHP: hero.HP, EnemyHP: enemy.HP, Timestamp: now, } } // 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, 0.30}, // Fire Demon: 30% burn {model.AbilityPoison, model.DebuffPoison, 0.10}, // Zombie: 10% poison {model.AbilitySlow, model.DebuffSlow, 0.25}, // Water Element: 25% slow (-40% movement) {model.AbilityStun, model.DebuffStun, 0.25}, // Lightning Titan: 25% stun {model.AbilityFreeze, model.DebuffFreeze, 0.20}, // Generic freeze: -50% attack speed {model.AbilityIceSlow, model.DebuffIceSlow, 0.20}, // Ice Guardian: -20% attack speed (spec §4.2) } 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.DefaultDebuffs[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 _, ad := range hero.Debuffs { if ad.IsExpired(now) { continue } switch ad.Debuff.Type { case model.DebuffPoison: // -2% HP/sec, scaled by tick duration. dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) if dmg < 1 { dmg = 1 } hero.HP -= dmg totalDmg += dmg case model.DebuffBurn: // -3% HP/sec, scaled by tick duration. dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) if dmg < 1 { dmg = 1 } 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. func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int { if !enemy.HasAbility(model.AbilityRegen) { return 0 } // Regen rates vary by enemy type. regenRate := 0.02 // default 2% per second switch enemy.Type { case model.EnemySkeletonKing: regenRate = 0.10 // 10% HP regen case model.EnemyForestWarden: regenRate = 0.05 // 5% HP/sec case model.EnemyBattleLizard: regenRate = 0.02 // 2% of received damage (approximated as 2% HP/sec) } healed := int(float64(enemy.MaxHP) * regenRate * tickDuration.Seconds()) if healed < 1 { healed = 1 } enemy.HP += healed if enemy.HP > enemy.MaxHP { enemy.HP = enemy.MaxHP } return healed } // 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.DefaultBuffs[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 from the Luck buff (x2.5 per spec §7.1). func LuckMultiplier(hero *model.Hero, now time.Time) float64 { if HasLuckBuff(hero, now) { return 2.5 } 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 } // How many 15-second summon cycles have elapsed since combat start. prevCycles := int(lastTick.Sub(combatStart).Seconds()) / 15 currCycles := int(now.Sub(combatStart).Seconds()) / 15 if currCycles <= prevCycles { return 0 } // Each summon wave deals 25% of the enemy's base attack as minion damage. minionDmg := max(1, enemy.Attack/4) hero.HP -= minionDmg if hero.HP < 0 { hero.HP = 0 } return minionDmg }