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
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
|
|
}
|