From 7f3b04b4240ab50d2bb2ef5a2bd0cbe14a82369a Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 01:14:56 +0300 Subject: [PATCH] another combat rework --- admin-web/index.html | 12 +++ backend/internal/game/combat.go | 100 ++++++++++++++++++++++--- backend/internal/game/combat_test.go | 63 ++++++++++++++++ backend/internal/game/engine.go | 55 ++++++++++++++ backend/internal/game/offline.go | 50 ++++++++++--- backend/internal/model/buff.go | 4 +- backend/internal/model/buff_catalog.go | 14 ++-- backend/internal/model/combat.go | 1 + backend/internal/model/hero.go | 60 ++++++++++----- backend/internal/model/hero_test.go | 43 +++++++++-- backend/internal/model/ws_message.go | 1 + backend/internal/tuning/runtime.go | 14 +++- docs/specification.md | 2 +- frontend/src/game/types.ts | 1 + 14 files changed, 363 insertions(+), 57 deletions(-) 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;