diff --git a/admin-web/index.html b/admin-web/index.html
index 60ef9ac..a14ccf2 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -219,8 +219,14 @@
npcCostPotion: "Стоимость зелья у NPC, золото.",
npcCostNearbyRadius: "Радиус «рядом с NPC» для ценообразования/проверок.",
combatDamageScale: "Глобальный масштаб исходящего урона героя в бою.",
+ combatDamageRollMin: "Мин. множитель случайного ролла урона (до защиты и крита).",
+ combatDamageRollMax: "Макс. множитель случайного ролла урона (до защиты и крита).",
+ heroCritChanceCap: "Верхняя граница шанса крита героя.",
+ heroBlockChancePerDefense: "Шанс блока героя на 1 единицу Defense.",
+ heroBlockChanceCap: "Верхняя граница шанса блока героя.",
enemyDodgeChance: "Базовая вероятность уклонения врага.",
enemyCriticalMinChance: "Нижняя планка шанса крита врага.",
+ enemyCritChanceCap: "Верхняя граница шанса крита врага.",
enemyBurstEveryN: "Враг наносит «всплеск» урона каждые N своих атак.",
enemyBurstMultiplier: "Множитель урона при всплеске.",
enemyChainEveryN: "Цепная атака врага каждые N ударов.",
@@ -406,6 +412,11 @@
npcCostPotion: "npc_costs",
npcCostNearbyRadius: "npc_costs",
combatDamageScale: "hero_combat",
+ combatDamageRollMin: "hero_combat",
+ combatDamageRollMax: "hero_combat",
+ heroCritChanceCap: "hero_combat",
+ heroBlockChancePerDefense: "hero_combat",
+ heroBlockChanceCap: "hero_combat",
minAttackIntervalMs: "hero_combat",
combatPaceMultiplier: "hero_combat",
agilityCoef: "hero_combat",
@@ -413,6 +424,7 @@
minAttackSpeed: "hero_combat",
enemyDodgeChance: "enemy_combat",
enemyCriticalMinChance: "enemy_combat",
+ enemyCritChanceCap: "enemy_combat",
enemyBurstEveryN: "enemy_combat",
enemyBurstMultiplier: "enemy_combat",
enemyChainEveryN: "enemy_combat",
diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go
index 5642449..649c61c 100644
--- a/backend/internal/game/combat.go
+++ b/backend/internal/game/combat.go
@@ -8,18 +8,38 @@ import (
"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,
// applying defense and critical hits.
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).
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
@@ -30,11 +50,31 @@ func CalculateDamage(baseAttack int, defense int, critChance float64) (damage in
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.
-func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.Time) int {
+// 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 {
@@ -45,6 +85,14 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.T
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
@@ -62,16 +110,14 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID,
Damage: 0,
Source: "hero",
+ Outcome: attackOutcomeStun,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
}
}
- critChance := 0.0
- if weapon := hero.Gear[model.SlotMainHand]; weapon != nil {
- critChance = weapon.CritChance
- }
+ critChance := heroCritChance(hero, now)
// Check enemy dodge ability.
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,
Damage: 0,
Source: "hero",
+ Outcome: attackOutcomeDodge,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
@@ -99,6 +146,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID,
Damage: dmg,
Source: "hero",
+ Outcome: attackOutcomeHit,
IsCrit: isCrit,
HeroHP: hero.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 {
critChance = tuning.Get().EnemyCriticalMinChance
}
+ critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap)
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)
}
- 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
if hero.HP < 0 {
@@ -156,6 +218,7 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
HeroID: hero.ID,
Damage: dmg,
Source: "enemy",
+ Outcome: attackOutcomeHit,
IsCrit: isCrit,
DebuffApplied: debuffApplied,
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.
// Returns the debuff type string if one was applied, empty string otherwise.
func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string {
diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go
index 3bb9351..b0365e1 100644
--- a/backend/internal/game/combat_test.go
+++ b/backend/internal/game/combat_test.go
@@ -1,10 +1,12 @@
package game
import (
+ "math/rand"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
+ "github.com/denisovdennis/autohero/internal/tuning"
)
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 {
b, ok := model.BuffDefinition(bt)
if !ok {
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index 90eb64d..a417c98 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -373,6 +373,9 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
return
}
+ hero.RefreshDerivedCombatStats(now)
+ hm.refreshSpeed(now)
+
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hero
}
@@ -386,6 +389,7 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
}
if e.sender != nil {
+ e.sender.SendToHero(msg.HeroID, "hero_state", hero)
e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
BuffType: payload.BuffType,
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)
e.emitEvent(combatEvt)
+ e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
@@ -1228,6 +1233,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
+ Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied,
@@ -1256,6 +1262,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt)
+ e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
@@ -1263,6 +1270,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
+ Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
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) {
hero := cs.Hero
enemy := &cs.Enemy
diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go
index 44581dc..fd658f7 100644
--- a/backend/internal/game/offline.go
+++ b/backend/internal/game/offline.go
@@ -311,19 +311,47 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
- heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense
- if heroDmgPerHit < 1 {
- heroDmgPerHit = 1
- }
- enemyDmgPerHit := enemy.Attack - hero.EffectiveDefenseAt(now)
- if enemyDmgPerHit < 1 {
- enemyDmgPerHit = 1
- }
+ combatStart := now
+ lastTick := now
+ heroNext := now.Add(attackInterval(hero.EffectiveSpeedAt(now)))
+ enemyNext := now.Add(attackInterval(enemy.Speed))
+ const maxCombatSteps = 100000
+ for step := 0; step < maxCombatSteps && hero.IsAlive() && enemy.IsAlive(); step++ {
+ 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
- dmgTaken := enemyDmgPerHit * hitsToKill
+ tickDur := nextTime.Sub(lastTick)
+ 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.
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 {
diff --git a/backend/internal/model/buff.go b/backend/internal/model/buff.go
index e23ead9..1a332f9 100644
--- a/backend/internal/model/buff.go
+++ b/backend/internal/model/buff.go
@@ -7,7 +7,7 @@ import "time"
type BuffType string
const (
- BuffRush BuffType = "rush" // +attack speed
+ BuffRush BuffType = "rush" // +movement speed
BuffRage BuffType = "rage" // +damage
BuffShield BuffType = "shield" // -incoming damage
BuffLuck BuffType = "luck" // loot mult from tuning.LuckBuffMultiplier when active
@@ -64,7 +64,7 @@ const (
DebuffBurn DebuffType = "burn" // -3% HP/sec
DebuffStun DebuffType = "stun" // no attacks for 2 sec
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)
)
diff --git a/backend/internal/model/buff_catalog.go b/backend/internal/model/buff_catalog.go
index cece615..5b41711 100644
--- a/backend/internal/model/buff_catalog.go
+++ b/backend/internal/model/buff_catalog.go
@@ -46,17 +46,17 @@ func seedBuffMap() map[BuffType]Buff {
return map[BuffType]Buff{
BuffRush: {
Type: BuffRush, Name: "Rush",
- Duration: 5 * time.Minute, Magnitude: 0.30,
+ Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 15 * time.Minute,
},
BuffRage: {
Type: BuffRage, Name: "Rage",
- Duration: 3 * time.Minute, Magnitude: 0.50,
+ Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute,
},
BuffShield: {
Type: BuffShield, Name: "Shield",
- Duration: 5 * time.Minute, Magnitude: 0.35,
+ Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 12 * time.Minute,
},
BuffLuck: {
@@ -66,22 +66,22 @@ func seedBuffMap() map[BuffType]Buff {
},
BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection",
- Duration: 10 * time.Minute, Magnitude: 0.40,
+ Duration: 10 * time.Minute, Magnitude: 0.50,
CooldownDuration: 30 * time.Minute,
},
BuffHeal: {
Type: BuffHeal, Name: "Heal",
- Duration: 1 * time.Second, Magnitude: 0.35,
+ Duration: 1 * time.Second, Magnitude: 0.50,
CooldownDuration: 5 * time.Minute,
},
BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion",
- Duration: 5 * time.Minute, Magnitude: 0.85,
+ Duration: 5 * time.Minute, Magnitude: 1.50,
CooldownDuration: 20 * time.Minute,
},
BuffWarCry: {
Type: BuffWarCry, Name: "War Cry",
- Duration: 3 * time.Minute, Magnitude: 0.50,
+ Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute,
},
}
diff --git a/backend/internal/model/combat.go b/backend/internal/model/combat.go
index df4f164..f0d88c5 100644
--- a/backend/internal/model/combat.go
+++ b/backend/internal/model/combat.go
@@ -57,6 +57,7 @@ type CombatEvent struct {
HeroID int64 `json:"heroId"`
Damage int `json:"damage,omitempty"`
Source string `json:"source"` // "hero" or "enemy"
+ Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
IsCrit bool `json:"isCrit,omitempty"`
DebuffApplied string `json:"debuffApplied,omitempty"` // debuff type applied this event, if any
HeroHP int `json:"heroHp"`
diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go
index dda6c2e..5b89797 100644
--- a/backend/internal/model/hero.go
+++ b/backend/internal/model/hero.go
@@ -254,26 +254,14 @@ func (h *Hero) EffectiveAttackAt(now time.Time) int {
bonuses := h.activeStatBonuses(now)
effectiveStrength := h.Strength + bonuses.strengthBonus
effectiveAgility := h.Agility + bonuses.agilityBonus
- effectiveConstitution := h.Constitution + bonuses.constitutionBonus
if chest := h.Gear[SlotChest]; chest != nil {
effectiveAgility += chest.AgilityBonus
}
- atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + effectiveConstitution/8
- if weapon := h.Gear[SlotMainHand]; weapon != nil {
- atk += weapon.PrimaryStat
- }
+ gearAttack, _ := h.gearPrimaryBonuses()
+ atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack
atkF := float64(atk)
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 {
atkF = 1
}
@@ -293,10 +281,8 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
effectiveAgility += chest.AgilityBonus
}
- def := h.Defense + effectiveConstitution/4 + effectiveAgility/8
- if chest := h.Gear[SlotChest]; chest != nil {
- def += chest.PrimaryStat
- }
+ _, gearDefense := h.gearPrimaryBonuses()
+ def := h.Defense + effectiveConstitution + effectiveAgility/4 + gearDefense
def = int(float64(def) * bonuses.defenseMultiplier)
if def < 0 {
def = 0
@@ -304,6 +290,44 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
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).
// Rush buff and Slow debuff affect movement, not attack speed, per spec §7.
func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 {
diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go
index be924e6..a9c7d1e 100644
--- a/backend/internal/model/hero_test.go
+++ b/backend/internal/model/hero_test.go
@@ -21,25 +21,27 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
SlotMainHand: {
PrimaryStat: 5,
SpeedModifier: 1.3,
+ StatType: "attack",
},
SlotChest: {
PrimaryStat: 4,
SpeedModifier: 0.7,
AgilityBonus: -3,
+ StatType: "defense",
},
},
}
gotAttack := hero.EffectiveAttackAt(now)
- // atk = 10 + 10*2 + 3/4 + 8/8 = 31 + weapon.PrimaryStat(5) = 36
- if gotAttack != 36 {
- t.Fatalf("expected attack 36, got %d", gotAttack)
+ // atk = 10 + 10*2 + 3/4 = 30 + weapon.PrimaryStat(5) = 35
+ if gotAttack != 35 {
+ t.Fatalf("expected attack 35, got %d", gotAttack)
}
gotDefense := hero.EffectiveDefenseAt(now)
- // def = 5 + 8/4 + 3/8 = 7 + chest.PrimaryStat(4) = 11
- if gotDefense != 11 {
- t.Fatalf("expected defense 11, got %d", gotDefense)
+ // def = 5 + 8 + 3/4 = 13 + chest.PrimaryStat(4) = 17
+ if gotDefense != 17 {
+ t.Fatalf("expected defense 17, got %d", gotDefense)
}
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) {
now := time.Now()
hero := &Hero{
@@ -261,7 +288,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
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)
}
- 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))
}
})
@@ -270,7 +297,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
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)
}
- 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))
}
})
diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go
index 5d47461..f9beb69 100644
--- a/backend/internal/model/ws_message.go
+++ b/backend/internal/model/ws_message.go
@@ -83,6 +83,7 @@ type AttackPayload struct {
Source string `json:"source"` // "hero" or "enemy"
Damage int `json:"damage"`
IsCrit bool `json:"isCrit,omitempty"`
+ Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
HeroHP int `json:"heroHp"`
EnemyHP int `json:"enemyHp"`
DebuffApplied string `json:"debuffApplied,omitempty"`
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index 1535425..995df6e 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -85,8 +85,14 @@ type Values struct {
QuestOffersPerNPC int `json:"questOffersPerNPC"`
CombatDamageScale float64 `json:"combatDamageScale"`
+ CombatDamageRollMin float64 `json:"combatDamageRollMin"`
+ CombatDamageRollMax float64 `json:"combatDamageRollMax"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
+ EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
+ HeroCritChanceCap float64 `json:"heroCritChanceCap"`
+ HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"`
+ HeroBlockChanceCap float64 `json:"heroBlockChanceCap"`
EnemyBurstEveryN int64 `json:"enemyBurstEveryN"`
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"`
@@ -271,8 +277,14 @@ func DefaultValues() Values {
NPCCostNearbyRadius: 3.0,
QuestOffersPerNPC: 2,
CombatDamageScale: 0.35,
+ CombatDamageRollMin: 0.60,
+ CombatDamageRollMax: 1.10,
EnemyDodgeChance: 0.20,
- EnemyCriticalMinChance: 0.15,
+ EnemyCriticalMinChance: 0.10,
+ EnemyCritChanceCap: 0.20,
+ HeroCritChanceCap: 0.12,
+ HeroBlockChancePerDefense: 0.0025,
+ HeroBlockChanceCap: 0.20,
EnemyBurstEveryN: 3,
EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6,
diff --git a/docs/specification.md b/docs/specification.md
index 1064c34..73b3525 100644
--- a/docs/specification.md
+++ b/docs/specification.md
@@ -549,7 +549,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Горение | -3% HP/сек |
| Оглушение | Нет атак (2 сек) |
| Замедление | -40% Movement |
-| Ослабление | -30% входящий урон |
+| Ослабление | +30% входящий урон |
---
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index c96de29..cea8d99 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -457,6 +457,7 @@ export interface AttackPayload {
source: 'hero' | 'enemy' | 'potion';
damage: number;
isCrit: boolean;
+ outcome?: 'hit' | 'dodge' | 'block' | 'stun';
heroHp: number;
enemyHp: number;
debuffApplied?: string;