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.

526 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 _, 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. 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 vary by enemy type.
cfg := tuning.Get()
regenRate := cfg.EnemyRegenDefault
switch enemy.Type {
case model.EnemySkeletonKing:
regenRate = cfg.EnemyRegenSkeletonKing
case model.EnemyForestWarden:
regenRate = cfg.EnemyRegenForestWarden
case model.EnemyBattleLizard:
regenRate = cfg.EnemyRegenBattleLizard
}
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
}