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.

415 lines
11 KiB
Go

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
}