another combat rework

master
Denis Ranneft 1 month ago
parent c58bd80f58
commit 7f3b04b424

@ -219,8 +219,14 @@
npcCostPotion: "Стоимость зелья у NPC, золото.", npcCostPotion: "Стоимость зелья у NPC, золото.",
npcCostNearbyRadius: "Радиус «рядом с NPC» для ценообразования/проверок.", npcCostNearbyRadius: "Радиус «рядом с NPC» для ценообразования/проверок.",
combatDamageScale: "Глобальный масштаб исходящего урона героя в бою.", combatDamageScale: "Глобальный масштаб исходящего урона героя в бою.",
combatDamageRollMin: "Мин. множитель случайного ролла урона (до защиты и крита).",
combatDamageRollMax: "Макс. множитель случайного ролла урона (до защиты и крита).",
heroCritChanceCap: "Верхняя граница шанса крита героя.",
heroBlockChancePerDefense: "Шанс блока героя на 1 единицу Defense.",
heroBlockChanceCap: "Верхняя граница шанса блока героя.",
enemyDodgeChance: "Базовая вероятность уклонения врага.", enemyDodgeChance: "Базовая вероятность уклонения врага.",
enemyCriticalMinChance: "Нижняя планка шанса крита врага.", enemyCriticalMinChance: "Нижняя планка шанса крита врага.",
enemyCritChanceCap: "Верхняя граница шанса крита врага.",
enemyBurstEveryN: "Враг наносит «всплеск» урона каждые N своих атак.", enemyBurstEveryN: "Враг наносит «всплеск» урона каждые N своих атак.",
enemyBurstMultiplier: "Множитель урона при всплеске.", enemyBurstMultiplier: "Множитель урона при всплеске.",
enemyChainEveryN: "Цепная атака врага каждые N ударов.", enemyChainEveryN: "Цепная атака врага каждые N ударов.",
@ -406,6 +412,11 @@
npcCostPotion: "npc_costs", npcCostPotion: "npc_costs",
npcCostNearbyRadius: "npc_costs", npcCostNearbyRadius: "npc_costs",
combatDamageScale: "hero_combat", combatDamageScale: "hero_combat",
combatDamageRollMin: "hero_combat",
combatDamageRollMax: "hero_combat",
heroCritChanceCap: "hero_combat",
heroBlockChancePerDefense: "hero_combat",
heroBlockChanceCap: "hero_combat",
minAttackIntervalMs: "hero_combat", minAttackIntervalMs: "hero_combat",
combatPaceMultiplier: "hero_combat", combatPaceMultiplier: "hero_combat",
agilityCoef: "hero_combat", agilityCoef: "hero_combat",
@ -413,6 +424,7 @@
minAttackSpeed: "hero_combat", minAttackSpeed: "hero_combat",
enemyDodgeChance: "enemy_combat", enemyDodgeChance: "enemy_combat",
enemyCriticalMinChance: "enemy_combat", enemyCriticalMinChance: "enemy_combat",
enemyCritChanceCap: "enemy_combat",
enemyBurstEveryN: "enemy_combat", enemyBurstEveryN: "enemy_combat",
enemyBurstMultiplier: "enemy_combat", enemyBurstMultiplier: "enemy_combat",
enemyChainEveryN: "enemy_combat", enemyChainEveryN: "enemy_combat",

@ -8,18 +8,38 @@ import (
"github.com/denisovdennis/autohero/internal/tuning" "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 the final damage dealt from attacker stats to a defender, // CalculateDamage computes the final damage dealt from attacker stats to a defender,
// applying defense and critical hits. // applying defense and critical hits.
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) { func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) {
atk := float64(baseAttack) breakdown := calculateDamageBreakdown(baseAttack, defense, critChance)
return breakdown.FinalDamage, breakdown.IsCrit
}
func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) DamageBreakdown {
atk := float64(baseAttack) * damageRollMultiplier()
// Defense reduces damage (simple formula: damage = atk - def, min 1). // Defense reduces damage (simple formula: damage = atk - def, min 1).
dmg := atk - float64(defense) dmg := atk - float64(defense)
if dmg < 1 { if dmg < 1 {
dmg = 1 dmg = 1
} }
raw := int(dmg)
// Critical hit check. // Critical hit check.
isCrit := false
if critChance > 0 && rand.Float64() < critChance { if critChance > 0 && rand.Float64() < critChance {
dmg *= 2 dmg *= 2
isCrit = true isCrit = true
@ -30,11 +50,31 @@ func CalculateDamage(baseAttack int, defense int, critChance float64) (damage in
dmg = 1 dmg = 1
} }
return int(dmg), isCrit return DamageBreakdown{
RawDamage: raw,
FinalDamage: int(dmg),
IsCrit: isCrit,
}
}
func damageRollMultiplier() float64 {
cfg := tuning.Get()
minRoll := cfg.CombatDamageRollMin
maxRoll := cfg.CombatDamageRollMax
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 reduction to incoming damage. // CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.Time) int { func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
dmg := float64(rawDamage) dmg := float64(rawDamage)
for _, ab := range buffs { for _, ab := range buffs {
@ -45,6 +85,14 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.T
dmg *= (1 - ab.Buff.Magnitude) 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 { if dmg < 1 {
dmg = 1 dmg = 1
@ -62,16 +110,14 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID, HeroID: hero.ID,
Damage: 0, Damage: 0,
Source: "hero", Source: "hero",
Outcome: attackOutcomeStun,
HeroHP: hero.HP, HeroHP: hero.HP,
EnemyHP: enemy.HP, EnemyHP: enemy.HP,
Timestamp: now, Timestamp: now,
} }
} }
critChance := 0.0 critChance := heroCritChance(hero, now)
if weapon := hero.Gear[model.SlotMainHand]; weapon != nil {
critChance = weapon.CritChance
}
// Check enemy dodge ability. // Check enemy dodge ability.
if enemy.HasAbility(model.AbilityDodge) { if enemy.HasAbility(model.AbilityDodge) {
@ -81,6 +127,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID, HeroID: hero.ID,
Damage: 0, Damage: 0,
Source: "hero", Source: "hero",
Outcome: attackOutcomeDodge,
HeroHP: hero.HP, HeroHP: hero.HP,
EnemyHP: enemy.HP, EnemyHP: enemy.HP,
Timestamp: now, Timestamp: now,
@ -99,6 +146,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID, HeroID: hero.ID,
Damage: dmg, Damage: dmg,
Source: "hero", Source: "hero",
Outcome: attackOutcomeHit,
IsCrit: isCrit, IsCrit: isCrit,
HeroHP: hero.HP, HeroHP: hero.HP,
EnemyHP: enemy.HP, EnemyHP: enemy.HP,
@ -133,6 +181,7 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
if enemy.HasAbility(model.AbilityCritical) && critChance < tuning.Get().EnemyCriticalMinChance { if enemy.HasAbility(model.AbilityCritical) && critChance < tuning.Get().EnemyCriticalMinChance {
critChance = tuning.Get().EnemyCriticalMinChance critChance = tuning.Get().EnemyCriticalMinChance
} }
critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap)
rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance) rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance)
@ -142,7 +191,20 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
rawDmg = int(float64(rawDmg) * burstMult) rawDmg = int(float64(rawDmg) * burstMult)
} }
dmg := CalculateIncomingDamage(rawDmg, hero.Buffs, now) 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 hero.HP -= dmg
if hero.HP < 0 { if hero.HP < 0 {
@ -156,6 +218,7 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
HeroID: hero.ID, HeroID: hero.ID,
Damage: dmg, Damage: dmg,
Source: "enemy", Source: "enemy",
Outcome: attackOutcomeHit,
IsCrit: isCrit, IsCrit: isCrit,
DebuffApplied: debuffApplied, DebuffApplied: debuffApplied,
HeroHP: hero.HP, HeroHP: hero.HP,
@ -164,6 +227,25 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
} }
} }
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. // tryApplyDebuff checks enemy abilities and rolls to apply debuffs to the hero.
// Returns the debuff type string if one was applied, empty string otherwise. // Returns the debuff type string if one was applied, empty string otherwise.
func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string { func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string {

@ -1,10 +1,12 @@
package game package game
import ( import (
"math/rand"
"testing" "testing"
"time" "time"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
) )
func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) { func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
@ -221,6 +223,67 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
} }
} }
func TestCritChanceCapsApply(t *testing.T) {
orig := tuning.Get()
t.Cleanup(func() {
tuning.Set(orig)
})
cfg := tuning.DefaultValues()
cfg.HeroCritChanceCap = 0.10
cfg.EnemyCritChanceCap = 0.20
cfg.EnemyCriticalMinChance = 0.0
cfg.HeroBlockChancePerDefense = 0.0
cfg.HeroBlockChanceCap = 0.0
tuning.Set(cfg)
hero := &model.Hero{
ID: 1, HP: 100, MaxHP: 100,
Attack: 20, Defense: 0, Speed: 1.0,
Strength: 5, Agility: 5,
Gear: map[model.EquipmentSlot]*model.GearItem{
model.SlotMainHand: {CritChance: 0.9, StatType: "attack"},
},
}
enemy := &model.Enemy{
MaxHP: 100,
HP: 100,
Attack: 10,
Defense: 0,
Speed: 1.0,
}
rand.Seed(1)
heroEvt := ProcessAttack(hero, enemy, time.Now())
if heroEvt.IsCrit {
t.Fatalf("expected hero crit to be capped off, got crit")
}
rand.Seed(1)
enemy.CritChance = 0.9
enemyEvt := ProcessEnemyAttack(hero, enemy, time.Now())
if enemyEvt.IsCrit {
t.Fatalf("expected enemy crit to be capped off, got crit")
}
}
func TestDamageRollAppliesRange(t *testing.T) {
orig := tuning.Get()
t.Cleanup(func() {
tuning.Set(orig)
})
cfg := tuning.DefaultValues()
cfg.CombatDamageScale = 1.0
cfg.CombatDamageRollMin = 0.5
cfg.CombatDamageRollMax = 0.5
tuning.Set(cfg)
rand.Seed(1)
breakdown := calculateDamageBreakdown(10, 0, 0)
if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 {
t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage)
}
}
func mustBuffDef(bt model.BuffType) model.Buff { func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt) b, ok := model.BuffDefinition(bt)
if !ok { if !ok {

@ -373,6 +373,9 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
return return
} }
hero.RefreshDerivedCombatStats(now)
hm.refreshSpeed(now)
if cs, ok := e.combats[msg.HeroID]; ok { if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hero cs.Hero = hero
} }
@ -386,6 +389,7 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
} }
if e.sender != nil { if e.sender != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{ e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
BuffType: payload.BuffType, BuffType: payload.BuffType,
Duration: ab.Buff.Duration.Seconds(), Duration: ab.Buff.Duration.Seconds(),
@ -1221,6 +1225,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now) combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt) e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope. // Push attack envelope.
if e.sender != nil { if e.sender != nil {
@ -1228,6 +1233,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source, Source: combatEvt.Source,
Damage: combatEvt.Damage, Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit, IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP, HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
@ -1256,6 +1262,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt) e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope. // Push attack envelope.
if e.sender != nil { if e.sender != nil {
@ -1263,6 +1270,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source, Source: combatEvt.Source,
Damage: combatEvt.Damage, Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit, IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP, HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
@ -1305,6 +1313,53 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
}) })
} }
func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
enemyName := cs.Enemy.Name
critSuffix := ""
if evt.IsCrit {
critSuffix = " (crit)"
}
var msg string
switch evt.Source {
case "hero":
switch evt.Outcome {
case attackOutcomeStun:
msg = "You are stunned and cannot attack."
case attackOutcomeDodge:
msg = enemyName + " dodged your attack."
default:
msg = "You hit " + enemyName + " for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
case "enemy":
switch evt.Outcome {
case attackOutcomeBlock:
msg = "You block " + enemyName + "'s attack."
default:
msg = enemyName + " hits you for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
}
if evt.DebuffApplied != "" {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
if msg != "" {
e.adventureLog(cs.HeroID, msg)
}
}
func debuffDisplayName(debuffType string) string {
dt, ok := model.ValidDebuffType(debuffType)
if !ok {
return debuffType
}
if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
}
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
hero := cs.Hero hero := cs.Hero
enemy := &cs.Enemy enemy := &cs.Enemy

@ -311,19 +311,47 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY) allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense combatStart := now
if heroDmgPerHit < 1 { lastTick := now
heroDmgPerHit = 1 heroNext := now.Add(attackInterval(hero.EffectiveSpeedAt(now)))
} enemyNext := now.Add(attackInterval(enemy.Speed))
enemyDmgPerHit := enemy.Attack - hero.EffectiveDefenseAt(now) const maxCombatSteps = 100000
if enemyDmgPerHit < 1 { for step := 0; step < maxCombatSteps && hero.IsAlive() && enemy.IsAlive(); step++ {
enemyDmgPerHit = 1 nextTime := heroNext
} isHero := true
if enemyNext.Before(heroNext) {
nextTime = enemyNext
isHero = false
}
if !nextTime.After(lastTick) {
nextTime = lastTick.Add(time.Millisecond)
}
hitsToKill := (enemy.MaxHP + heroDmgPerHit - 1) / heroDmgPerHit tickDur := nextTime.Sub(lastTick)
dmgTaken := enemyDmgPerHit * hitsToKill if tickDur > 0 {
ProcessDebuffDamage(hero, tickDur, nextTime)
ProcessEnemyRegen(&enemy, tickDur)
ProcessSummonDamage(hero, &enemy, combatStart, lastTick, nextTime)
}
lastTick = nextTime
if CheckDeath(hero, nextTime) {
break
}
hero.HP -= dmgTaken if isHero {
ProcessAttack(hero, &enemy, nextTime)
if !enemy.IsAlive() {
break
}
heroNext = nextTime.Add(attackInterval(hero.EffectiveSpeedAt(nextTime)))
} else {
ProcessEnemyAttack(hero, &enemy, nextTime)
if CheckDeath(hero, nextTime) {
break
}
enemyNext = nextTime.Add(attackInterval(enemy.Speed))
}
}
// Use potion if HP drops below 30% and hero has potions. // Use potion if HP drops below 30% and hero has potions.
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 { if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 {

@ -7,7 +7,7 @@ import "time"
type BuffType string type BuffType string
const ( const (
BuffRush BuffType = "rush" // +attack speed BuffRush BuffType = "rush" // +movement speed
BuffRage BuffType = "rage" // +damage BuffRage BuffType = "rage" // +damage
BuffShield BuffType = "shield" // -incoming damage BuffShield BuffType = "shield" // -incoming damage
BuffLuck BuffType = "luck" // loot mult from tuning.LuckBuffMultiplier when active BuffLuck BuffType = "luck" // loot mult from tuning.LuckBuffMultiplier when active
@ -64,7 +64,7 @@ const (
DebuffBurn DebuffType = "burn" // -3% HP/sec DebuffBurn DebuffType = "burn" // -3% HP/sec
DebuffStun DebuffType = "stun" // no attacks for 2 sec DebuffStun DebuffType = "stun" // no attacks for 2 sec
DebuffSlow DebuffType = "slow" // -40% movement speed (not attack speed) DebuffSlow DebuffType = "slow" // -40% movement speed (not attack speed)
DebuffWeaken DebuffType = "weaken" // -30% hero outgoing damage DebuffWeaken DebuffType = "weaken" // -30% hero incoming damage
DebuffIceSlow DebuffType = "ice_slow" // -20% attack speed (Ice Guardian per spec §4.2) DebuffIceSlow DebuffType = "ice_slow" // -20% attack speed (Ice Guardian per spec §4.2)
) )

@ -46,17 +46,17 @@ func seedBuffMap() map[BuffType]Buff {
return map[BuffType]Buff{ return map[BuffType]Buff{
BuffRush: { BuffRush: {
Type: BuffRush, Name: "Rush", Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 0.30, Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 15 * time.Minute, CooldownDuration: 15 * time.Minute,
}, },
BuffRage: { BuffRage: {
Type: BuffRage, Name: "Rage", Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 0.50, Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute, CooldownDuration: 10 * time.Minute,
}, },
BuffShield: { BuffShield: {
Type: BuffShield, Name: "Shield", Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 0.35, Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 12 * time.Minute, CooldownDuration: 12 * time.Minute,
}, },
BuffLuck: { BuffLuck: {
@ -66,22 +66,22 @@ func seedBuffMap() map[BuffType]Buff {
}, },
BuffResurrection: { BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection", Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 0.40, Duration: 10 * time.Minute, Magnitude: 0.50,
CooldownDuration: 30 * time.Minute, CooldownDuration: 30 * time.Minute,
}, },
BuffHeal: { BuffHeal: {
Type: BuffHeal, Name: "Heal", Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 0.35, Duration: 1 * time.Second, Magnitude: 0.50,
CooldownDuration: 5 * time.Minute, CooldownDuration: 5 * time.Minute,
}, },
BuffPowerPotion: { BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion", Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 0.85, Duration: 5 * time.Minute, Magnitude: 1.50,
CooldownDuration: 20 * time.Minute, CooldownDuration: 20 * time.Minute,
}, },
BuffWarCry: { BuffWarCry: {
Type: BuffWarCry, Name: "War Cry", Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 0.50, Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute, CooldownDuration: 10 * time.Minute,
}, },
} }

@ -57,6 +57,7 @@ type CombatEvent struct {
HeroID int64 `json:"heroId"` HeroID int64 `json:"heroId"`
Damage int `json:"damage,omitempty"` Damage int `json:"damage,omitempty"`
Source string `json:"source"` // "hero" or "enemy" Source string `json:"source"` // "hero" or "enemy"
Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
IsCrit bool `json:"isCrit,omitempty"` IsCrit bool `json:"isCrit,omitempty"`
DebuffApplied string `json:"debuffApplied,omitempty"` // debuff type applied this event, if any DebuffApplied string `json:"debuffApplied,omitempty"` // debuff type applied this event, if any
HeroHP int `json:"heroHp"` HeroHP int `json:"heroHp"`

@ -254,26 +254,14 @@ func (h *Hero) EffectiveAttackAt(now time.Time) int {
bonuses := h.activeStatBonuses(now) bonuses := h.activeStatBonuses(now)
effectiveStrength := h.Strength + bonuses.strengthBonus effectiveStrength := h.Strength + bonuses.strengthBonus
effectiveAgility := h.Agility + bonuses.agilityBonus effectiveAgility := h.Agility + bonuses.agilityBonus
effectiveConstitution := h.Constitution + bonuses.constitutionBonus
if chest := h.Gear[SlotChest]; chest != nil { if chest := h.Gear[SlotChest]; chest != nil {
effectiveAgility += chest.AgilityBonus effectiveAgility += chest.AgilityBonus
} }
atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + effectiveConstitution/8 gearAttack, _ := h.gearPrimaryBonuses()
if weapon := h.Gear[SlotMainHand]; weapon != nil { atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack
atk += weapon.PrimaryStat
}
atkF := float64(atk) atkF := float64(atk)
atkF *= bonuses.attackMultiplier atkF *= bonuses.attackMultiplier
// Apply weaken debuff.
for _, ad := range h.Debuffs {
if ad.IsExpired(now) {
continue
}
if ad.Debuff.Type == DebuffWeaken {
atkF *= (1 - ad.Debuff.Magnitude) // -30% outgoing damage
}
}
if atkF < 1 { if atkF < 1 {
atkF = 1 atkF = 1
} }
@ -293,10 +281,8 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
effectiveAgility += chest.AgilityBonus effectiveAgility += chest.AgilityBonus
} }
def := h.Defense + effectiveConstitution/4 + effectiveAgility/8 _, gearDefense := h.gearPrimaryBonuses()
if chest := h.Gear[SlotChest]; chest != nil { def := h.Defense + effectiveConstitution + effectiveAgility/4 + gearDefense
def += chest.PrimaryStat
}
def = int(float64(def) * bonuses.defenseMultiplier) def = int(float64(def) * bonuses.defenseMultiplier)
if def < 0 { if def < 0 {
def = 0 def = 0
@ -304,6 +290,44 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
return def return def
} }
// EffectiveBlockChance returns the hero's block chance after buffs and defense scaling.
func (h *Hero) EffectiveBlockChance(now time.Time) float64 {
cfg := tuning.Get()
bonuses := h.activeStatBonuses(now)
chance := float64(h.EffectiveDefenseAt(now))*cfg.HeroBlockChancePerDefense + bonuses.blockChanceBonus
if chance < 0 {
chance = 0
}
if cfg.HeroBlockChanceCap > 0 && chance > cfg.HeroBlockChanceCap {
chance = cfg.HeroBlockChanceCap
}
return chance
}
// gearPrimaryBonuses sums primary stats from all equipped gear by statType.
// Mixed items split their primary stat between attack and defense.
func (h *Hero) gearPrimaryBonuses() (attackBonus int, defenseBonus int) {
if h.Gear == nil {
return 0, 0
}
for _, item := range h.Gear {
if item == nil {
continue
}
switch item.StatType {
case "attack":
attackBonus += item.PrimaryStat
case "defense":
defenseBonus += item.PrimaryStat
case "mixed":
half := item.PrimaryStat / 2
attackBonus += half
defenseBonus += item.PrimaryStat - half
}
}
return attackBonus, defenseBonus
}
// MovementSpeedMultiplier returns the hero's movement speed modifier (1.0 = normal). // MovementSpeedMultiplier returns the hero's movement speed modifier (1.0 = normal).
// Rush buff and Slow debuff affect movement, not attack speed, per spec §7. // Rush buff and Slow debuff affect movement, not attack speed, per spec §7.
func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 { func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 {

@ -21,25 +21,27 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
SlotMainHand: { SlotMainHand: {
PrimaryStat: 5, PrimaryStat: 5,
SpeedModifier: 1.3, SpeedModifier: 1.3,
StatType: "attack",
}, },
SlotChest: { SlotChest: {
PrimaryStat: 4, PrimaryStat: 4,
SpeedModifier: 0.7, SpeedModifier: 0.7,
AgilityBonus: -3, AgilityBonus: -3,
StatType: "defense",
}, },
}, },
} }
gotAttack := hero.EffectiveAttackAt(now) gotAttack := hero.EffectiveAttackAt(now)
// atk = 10 + 10*2 + 3/4 + 8/8 = 31 + weapon.PrimaryStat(5) = 36 // atk = 10 + 10*2 + 3/4 = 30 + weapon.PrimaryStat(5) = 35
if gotAttack != 36 { if gotAttack != 35 {
t.Fatalf("expected attack 36, got %d", gotAttack) t.Fatalf("expected attack 35, got %d", gotAttack)
} }
gotDefense := hero.EffectiveDefenseAt(now) gotDefense := hero.EffectiveDefenseAt(now)
// def = 5 + 8/4 + 3/8 = 7 + chest.PrimaryStat(4) = 11 // def = 5 + 8 + 3/4 = 13 + chest.PrimaryStat(4) = 17
if gotDefense != 11 { if gotDefense != 17 {
t.Fatalf("expected defense 11, got %d", gotDefense) t.Fatalf("expected defense 17, got %d", gotDefense)
} }
gotSpeed := hero.EffectiveSpeedAt(now) gotSpeed := hero.EffectiveSpeedAt(now)
@ -49,6 +51,31 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
} }
} }
func TestGearPrimaryBonusesAcrossSlots(t *testing.T) {
now := time.Now()
hero := &Hero{
Attack: 10,
Defense: 5,
Speed: 1.0,
Strength: 2,
Constitution: 3,
Agility: 4,
Gear: map[EquipmentSlot]*GearItem{
SlotMainHand: {PrimaryStat: 6, StatType: "attack"},
SlotHead: {PrimaryStat: 4, StatType: "defense"},
SlotChest: {PrimaryStat: 7, StatType: "defense"},
SlotFinger: {PrimaryStat: 5, StatType: "mixed"},
},
}
if got := hero.EffectiveAttackAt(now); got != 23 {
t.Fatalf("expected attack 23, got %d", got)
}
if got := hero.EffectiveDefenseAt(now); got != 23 {
t.Fatalf("expected defense 23, got %d", got)
}
}
func TestBuffsProvideTemporaryStatEffects(t *testing.T) { func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
now := time.Now() now := time.Now()
hero := &Hero{ hero := &Hero{
@ -261,7 +288,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
if h.MaxHP != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 { if h.MaxHP != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 {
t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
} }
if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 6 { if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })
@ -270,7 +297,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
if h.MaxHP != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 { if h.MaxHP != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 {
t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
} }
if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 6 { if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })

@ -83,6 +83,7 @@ type AttackPayload struct {
Source string `json:"source"` // "hero" or "enemy" Source string `json:"source"` // "hero" or "enemy"
Damage int `json:"damage"` Damage int `json:"damage"`
IsCrit bool `json:"isCrit,omitempty"` IsCrit bool `json:"isCrit,omitempty"`
Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
HeroHP int `json:"heroHp"` HeroHP int `json:"heroHp"`
EnemyHP int `json:"enemyHp"` EnemyHP int `json:"enemyHp"`
DebuffApplied string `json:"debuffApplied,omitempty"` DebuffApplied string `json:"debuffApplied,omitempty"`

@ -85,8 +85,14 @@ type Values struct {
QuestOffersPerNPC int `json:"questOffersPerNPC"` QuestOffersPerNPC int `json:"questOffersPerNPC"`
CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"`
CombatDamageRollMax float64 `json:"combatDamageRollMax"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
HeroCritChanceCap float64 `json:"heroCritChanceCap"`
HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"`
HeroBlockChanceCap float64 `json:"heroBlockChanceCap"`
EnemyBurstEveryN int64 `json:"enemyBurstEveryN"` EnemyBurstEveryN int64 `json:"enemyBurstEveryN"`
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"` EnemyChainEveryN int64 `json:"enemyChainEveryN"`
@ -271,8 +277,14 @@ func DefaultValues() Values {
NPCCostNearbyRadius: 3.0, NPCCostNearbyRadius: 3.0,
QuestOffersPerNPC: 2, QuestOffersPerNPC: 2,
CombatDamageScale: 0.35, CombatDamageScale: 0.35,
CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10,
EnemyDodgeChance: 0.20, EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.15, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12,
HeroBlockChancePerDefense: 0.0025,
HeroBlockChanceCap: 0.20,
EnemyBurstEveryN: 3, EnemyBurstEveryN: 3,
EnemyBurstMultiplier: 1.5, EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6, EnemyChainEveryN: 6,

@ -549,7 +549,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Горение | -3% HP/сек | | Горение | -3% HP/сек |
| Оглушение | Нет атак (2 сек) | | Оглушение | Нет атак (2 сек) |
| Замедление | -40% Movement | | Замедление | -40% Movement |
| Ослабление | -30% входящий урон | | Ослабление | +30% входящий урон |
--- ---

@ -457,6 +457,7 @@ export interface AttackPayload {
source: 'hero' | 'enemy' | 'potion'; source: 'hero' | 'enemy' | 'potion';
damage: number; damage: number;
isCrit: boolean; isCrit: boolean;
outcome?: 'hit' | 'dodge' | 'block' | 'stun';
heroHp: number; heroHp: number;
enemyHp: number; enemyHp: number;
debuffApplied?: string; debuffApplied?: string;

Loading…
Cancel
Save