another combat rework

master
Denis Ranneft 1 month ago
parent c58bd80f58
commit 7f3b04b424

@ -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",

@ -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,
}
}
// CalculateIncomingDamage applies shield buff reduction to incoming damage.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.Time) int {
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 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 {

@ -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 {

@ -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

@ -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)
}
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
}
hitsToKill := (enemy.MaxHP + heroDmgPerHit - 1) / heroDmgPerHit
dmgTaken := enemyDmgPerHit * hitsToKill
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 {

@ -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)
)

@ -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,
},
}

@ -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"`

@ -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 {

@ -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))
}
})

@ -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"`

@ -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,

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

@ -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;

Loading…
Cancel
Save