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.

529 lines
14 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 (magnitude fraction) and Weaken (+magnitude incoming) per spec §7.
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.Archetype {
case "skeleton_king":
regenRate = tuning.EffectiveEnemyRegenSkeletonKing()
case "forest_warden":
regenRate = tuning.EffectiveEnemyRegenForestWarden()
case "battle_lizard":
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
}