monsters/gear rebalance

master
Denis Ranneft 1 month ago
parent a415951876
commit 082f96f627

@ -56,7 +56,7 @@ func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile Ref
enemy = firstEnemyForBalance(level) enemy = firstEnemyForBalance(level)
case BalanceEnemyMixedSpawn: case BalanceEnemyMixedSpawn:
pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001)) pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001))
enemy = PickEnemyForLevelWithRNG(level, pickRNG) enemy = PickEnemyForLevelWithRNG(level, pickRNG, hero)
default: default:
enemy = firstEnemyForBalance(level) enemy = firstEnemyForBalance(level)
} }

@ -62,18 +62,21 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
} }
var wRarity, aRarity model.Rarity var wRarity, aRarity model.Rarity
var refGearBase int var refWeaponBase, refArmorBase int
switch profile { switch profile {
case ReferenceGearBaseline: case ReferenceGearBaseline:
wRarity, aRarity = model.RarityCommon, model.RarityCommon wRarity, aRarity = model.RarityCommon, model.RarityCommon
refGearBase = 7 refWeaponBase = 6 // postgear-nerf weapon base (~7×0.85)
refArmorBase = 3 // postgear-nerf chest (~4×0.7 for medium)
case ReferenceGearMax: case ReferenceGearMax:
wRarity, aRarity = model.RarityLegendary, model.RarityLegendary wRarity, aRarity = model.RarityLegendary, model.RarityLegendary
refGearBase = 7 refWeaponBase = 6
refArmorBase = 3
default: default:
// Median, Rolled, unknown: uncommon + mid-tier ref base (legacy balance target). // Median, Rolled, unknown: uncommon + mid-tier ref bases aligned with gear migration.
wRarity, aRarity = model.RarityUncommon, model.RarityUncommon wRarity, aRarity = model.RarityUncommon, model.RarityUncommon
refGearBase = 12 refWeaponBase = 10 // ~12×0.85
refArmorBase = 8 // ~12×0.7
} }
if profile == ReferenceGearBaseline || profile == ReferenceGearMax { if profile == ReferenceGearBaseline || profile == ReferenceGearMax {
@ -89,7 +92,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
} }
} }
if h.Gear[model.SlotMainHand] == nil { if h.Gear[model.SlotMainHand] == nil {
wPrimary := model.ScalePrimary(refGearBase, wIlvl, wRarity) wPrimary := model.ScalePrimary(refWeaponBase, wIlvl, wRarity)
h.Gear[model.SlotMainHand] = &model.GearItem{ h.Gear[model.SlotMainHand] = &model.GearItem{
Slot: model.SlotMainHand, Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword", FormID: "gear.form.main_hand.sword",
@ -97,7 +100,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
Subtype: "sword", Subtype: "sword",
Rarity: wRarity, Rarity: wRarity,
Ilvl: wIlvl, Ilvl: wIlvl,
BasePrimary: refGearBase, BasePrimary: refWeaponBase,
PrimaryStat: wPrimary, PrimaryStat: wPrimary,
StatType: "attack", StatType: "attack",
SpeedModifier: 1.0, SpeedModifier: 1.0,
@ -105,7 +108,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
} }
} }
if h.Gear[model.SlotChest] == nil { if h.Gear[model.SlotChest] == nil {
aPrimary := model.ScalePrimary(refGearBase, aIlvl, aRarity) aPrimary := model.ScalePrimary(refArmorBase, aIlvl, aRarity)
h.Gear[model.SlotChest] = &model.GearItem{ h.Gear[model.SlotChest] = &model.GearItem{
Slot: model.SlotChest, Slot: model.SlotChest,
FormID: "gear.form.chest.medium", FormID: "gear.form.chest.medium",
@ -113,7 +116,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
Subtype: "medium", Subtype: "medium",
Rarity: aRarity, Rarity: aRarity,
Ilvl: aIlvl, Ilvl: aIlvl,
BasePrimary: refGearBase, BasePrimary: refArmorBase,
PrimaryStat: aPrimary, PrimaryStat: aPrimary,
StatType: "defense", StatType: "defense",
SpeedModifier: 1.0, SpeedModifier: 1.0,

@ -674,7 +674,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// DB said fighting but engine has no combat (e.g. after restart): attach a new encounter. // DB said fighting but engine has no combat (e.g. after restart): attach a new encounter.
if hm.State == model.StateFighting { if hm.State == model.StateFighting {
if _, exists := e.combats[hero.ID]; !exists { if _, exists := e.combats[hero.ID]; !exists {
en := PickEnemyForLevel(hero.Level) en := PickEnemyForHero(hero)
if en.Slug != "" { if en.Slug != "" {
e.startCombatLocked(hm.Hero, &en) e.startCombatLocked(hm.Hero, &en)
} else { } else {

@ -64,7 +64,7 @@ func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.
hm.SyncToHero() hm.SyncToHero()
if hm.State == model.StateFighting { if hm.State == model.StateFighting {
if _, exists := e.combats[h.ID]; !exists { if _, exists := e.combats[h.ID]; !exists {
en := PickEnemyForLevel(h.Level) en := PickEnemyForHero(h)
if en.Slug != "" { if en.Slug != "" {
e.startCombatLocked(hm.Hero, &en) e.startCombatLocked(hm.Hero, &en)
} else { } else {

@ -1109,7 +1109,7 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
total := monsterW + merchantW total := monsterW + merchantW
r := rand.Float64() * total r := rand.Float64() * total
if r < monsterW { if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level) e := PickEnemyForHero(hm.Hero)
return true, e, true return true, e, true
} }
return false, model.Enemy{}, true return false, model.Enemy{}, true
@ -1877,7 +1877,7 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph)
total := monsterW + merchantW total := monsterW + merchantW
r := rand.Float64() * total r := rand.Float64() * total
if r < monsterW { if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level) e := PickEnemyForHero(hm.Hero)
return true, e, true return true, e, true
} }
return false, model.Enemy{}, true return false, model.Enemy{}, true

@ -419,7 +419,7 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
if encounterEnemy != nil { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
enemy = PickEnemyForLevel(hero.Level) enemy = PickEnemyForHero(hero)
} }
if rewardDeps.InTown == nil && g != nil { if rewardDeps.InTown == nil && g != nil {
rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool { rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
@ -458,26 +458,39 @@ func sumGoldFromDrops(drops []model.LootDrop) int64 {
} }
// PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance. // PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance.
// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier).
func PickEnemyForLevel(level int) model.Enemy { func PickEnemyForLevel(level int) model.Enemy {
candidates := enemyCandidatesForHeroLevel(level) return pickEnemyForHeroLevel(nil, level, nil)
if len(candidates) == 0 { }
// PickEnemyForHero is like PickEnemyForLevel but applies unequipped-hero monster scaling when hero has no gear.
func PickEnemyForHero(hero *model.Hero) model.Enemy {
if hero == nil {
return model.Enemy{} return model.Enemy{}
} }
picked := candidates[rand.Intn(len(candidates))] return pickEnemyForHeroLevel(hero, hero.Level, nil)
return buildEnemyInstance(picked, level, nil)
} }
// PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims). // PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims).
func PickEnemyForLevelWithRNG(level int, rng *rand.Rand) model.Enemy { // Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil).
if rng == nil { func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy {
return PickEnemyForLevel(level) return pickEnemyForHeroLevel(hero, level, rng)
} }
func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy {
candidates := enemyCandidatesForHeroLevel(level) candidates := enemyCandidatesForHeroLevel(level)
if len(candidates) == 0 { if len(candidates) == 0 {
return model.Enemy{} return model.Enemy{}
} }
picked := candidates[rng.Intn(len(candidates))] var picked model.Enemy
return buildEnemyInstance(picked, level, rng) if rng != nil {
picked = candidates[rng.Intn(len(candidates))]
} else {
picked = candidates[rand.Intn(len(candidates))]
}
e := buildEnemyInstance(picked, level, rng)
ApplyEnemyEncounterHeroScaling(hero, &e)
return e
} }
func enemyCandidatesForHeroLevel(level int) []model.Enemy { func enemyCandidatesForHeroLevel(level int) []model.Enemy {
@ -611,9 +624,73 @@ func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
} }
picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel))) picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel)))
picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel))) picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel)))
cfg := tuning.Get()
gMult := cfg.EnemyEncounterStatMultiplier
if gMult <= 0 {
gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier
}
if gMult > 0 && gMult != 1 {
applyEnemyEncounterCombatMult(&picked, gMult)
}
return picked return picked
} }
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
func HeroHasEquippedGear(h *model.Hero) bool {
if h == nil {
return false
}
h.EnsureGearMap()
for _, it := range h.Gear {
if it != nil {
return true
}
}
return false
}
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
if h == nil {
return false
}
h.EnsureGearMap()
var c = 0
for _, it := range h.Gear {
if it != nil {
c++
}
}
return c > 4
}
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
if e == nil || mult <= 0 || mult == 1 {
return
}
e.MaxHP = max(1, int(math.Round(float64(e.MaxHP)*mult)))
e.HP = e.MaxHP
e.Attack = max(1, int(math.Round(float64(e.Attack)*mult)))
e.Defense = max(0, int(math.Round(float64(e.Defense)*mult)))
}
// ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear.
func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) {
if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) {
return
}
cfg := tuning.Get()
m := cfg.EnemyStatMultiplierVsUnequippedHero
if m <= 0 {
m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
}
if m <= 0 || m > 10 || m == 1 {
return
}
applyEnemyEncounterCombatMult(enemy, m)
}
func absInt(v int) int { func absInt(v int) int {
if v < 0 { if v < 0 {
return -v return -v

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
) )
func TestOfflineDigestCollecting(t *testing.T) { func TestOfflineDigestCollecting(t *testing.T) {
@ -149,6 +150,50 @@ func TestNonGoldLootForDigest(t *testing.T) {
} }
} }
func TestBuildEnemyInstanceForLevel_EncounterStatMultiplier(t *testing.T) {
cfg := tuning.DefaultValues()
cfg.EnemyEncounterStatMultiplier = 2.0
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
tmpl := model.Enemy{
BaseLevel: 1,
MaxHP: 50,
HP: 50,
Attack: 10,
Defense: 4,
}
out := BuildEnemyInstanceForLevel(tmpl, 1)
if out.MaxHP != 100 || out.HP != 100 {
t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP)
}
if out.Attack != 20 || out.Defense != 8 {
t.Fatalf("Attack/Defense: got %d/%d want 20/8", out.Attack, out.Defense)
}
}
func TestApplyEnemyEncounterHeroScaling_Unequipped(t *testing.T) {
cfg := tuning.DefaultValues()
cfg.EnemyEncounterStatMultiplier = 1.0
cfg.EnemyStatMultiplierVsUnequippedHero = 0.75
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
hero := &model.Hero{Gear: make(map[model.EquipmentSlot]*model.GearItem)}
enemy := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8}
ApplyEnemyEncounterHeroScaling(hero, &enemy)
if enemy.MaxHP != 75 || enemy.HP != 75 || enemy.Attack != 15 || enemy.Defense != 6 {
t.Fatalf("scaled enemy: got hp=%d atk=%d def=%d", enemy.MaxHP, enemy.Attack, enemy.Defense)
}
hero.Gear[model.SlotMainHand] = &model.GearItem{PrimaryStat: 1, StatType: "attack"}
enemy2 := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8}
ApplyEnemyEncounterHeroScaling(hero, &enemy2)
if enemy2.MaxHP != 100 {
t.Fatalf("geared hero should not scale enemy: got MaxHP %d", enemy2.MaxHP)
}
}
func TestPickEnemyForLevel(t *testing.T) { func TestPickEnemyForLevel(t *testing.T) {
tests := []struct { tests := []struct {
level int level int

@ -364,7 +364,7 @@ func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds floa
hero := CloneHeroForCombatSim(baseHero) hero := CloneHeroForCombatSim(baseHero)
pickRNG := rand.New(rand.NewSource(seed + 11_111)) pickRNG := rand.New(rand.NewSource(seed + 11_111))
enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG) enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG, hero)
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{ survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond, TickRate: 100 * time.Millisecond,

@ -2301,7 +2301,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
return return
} }
enemy := game.PickEnemyForLevel(hm.Hero.Level) enemy := game.PickEnemyForHero(hm.Hero)
if enemy.Slug == "" || enemy.MaxHP <= 0 { if enemy.Slug == "" || enemy.MaxHP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"})
return return
@ -2466,6 +2466,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level". // Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil) enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil)
} }
game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
combatStart := game.CombatSimDeterministicStart combatStart := game.CombatSimDeterministicStart
hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart) hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart)

@ -454,7 +454,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
return return
} }
enemy := pickEnemyForLevel(hero.Level) enemy := pickEnemyForHero(hero)
h.encounterMu.Lock() h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock() h.encounterMu.Unlock()
@ -493,9 +493,9 @@ func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool
return false return false
} }
// pickEnemyForLevel delegates to the canonical implementation in the game package. // pickEnemyForHero delegates to the canonical implementation in the game package.
func pickEnemyForLevel(level int) model.Enemy { func pickEnemyForHero(hero *model.Hero) model.Enemy {
return game.PickEnemyForLevel(level) return game.PickEnemyForHero(hero)
} }
// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether // tryAutoEquipGear uses the in-memory combat rating comparison to decide whether

@ -110,6 +110,10 @@ type Values struct {
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"` EnemyChainEveryN int64 `json:"enemyChainEveryN"`
EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` EnemyChainMultiplier float64 `json:"enemyChainMultiplier"`
// EnemyEncounterStatMultiplier scales enemy MaxHP/HP/Attack/Defense after template+level math (default 1.2 = +20%).
EnemyEncounterStatMultiplier float64 `json:"enemyEncounterStatMultiplier"`
// EnemyStatMultiplierVsUnequippedHero scales the same stats when the hero has no equipped items (default 0.75 = 25%).
EnemyStatMultiplierVsUnequippedHero float64 `json:"enemyStatMultiplierVsUnequippedHero"`
DebuffProcBurn float64 `json:"debuffProcBurn"` DebuffProcBurn float64 `json:"debuffProcBurn"`
DebuffProcPoison float64 `json:"debuffProcPoison"` DebuffProcPoison float64 `json:"debuffProcPoison"`
@ -324,6 +328,8 @@ func DefaultValues() Values {
EnemyBurstMultiplier: 1.5, EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6, EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0, EnemyChainMultiplier: 3.0,
EnemyEncounterStatMultiplier: 1.2,
EnemyStatMultiplierVsUnequippedHero: 0.75,
DebuffProcBurn: 0.18, DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10, DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25, DebuffProcSlow: 0.25,

@ -0,0 +1,50 @@
-- Nerf weapon primaries (15% base) and defense/mixed primaries (30% base); recompute denormalized primary_stat (§6.4, same M(rarity) and tol as 000012).
-- Runtime: global enemy encounter stat multiplier + unequipped-hero multiplier (see tuning / BuildEnemyInstanceForLevel).
WITH adjusted AS (
SELECT
g.id,
GREATEST(
1,
ROUND(
g.base_primary::numeric * (
CASE
WHEN g.slot = 'main_hand' THEN 0.85
WHEN g.stat_type::text IN ('defense', 'mixed') THEN 0.70
ELSE 1.0
END
)
)::integer
) AS new_base
FROM public.gear AS g
)
UPDATE public.gear AS g
SET
base_primary = a.new_base,
primary_stat = GREATEST(
1,
ROUND(
a.new_base::numeric
* POWER(1.1, GREATEST(0, g.ilvl - 1))
* (CASE g.rarity::text
WHEN 'common' THEN 1.0
WHEN 'uncommon' THEN 1.0877573
WHEN 'rare' THEN 1.1832160
WHEN 'epic' THEN 1.2870518
WHEN 'legendary' THEN 1.40
ELSE 1.0
END)
* (0.92::numeric + (mod(g.id, 24)::numeric / 23.0) * 0.23)
)::integer
)
FROM adjusted AS a
WHERE g.id = a.id;
UPDATE public.runtime_config
SET
payload = payload || jsonb_build_object(
'enemyEncounterStatMultiplier', 1.2,
'enemyStatMultiplierVsUnequippedHero', 0.75
),
updated_at = now()
WHERE id = true;

@ -67,6 +67,8 @@ go run ./cmd/balanceall -gear-overlay ./cmd/balanceall/testdata/gear_overlay_bal
Команда завершает работу после вывода SQL. Перенесите результат в миграцию (см. пример `backend/migrations/000012_gear_balance_overlay.sql`). Команда завершает работу после вывода SQL. Перенесите результат в миграцию (см. пример `backend/migrations/000012_gear_balance_overlay.sql`).
После миграций, меняющих `gear` или множители встреч в `runtime_config` (`enemyEncounterStatMultiplier`, `enemyStatMultiplierVsUnequippedHero`), имеет смысл прогнать `-gear-check` и при необходимости сетку с `-adjust-enemies=false`, чтобы увидеть метрики; `BuildEnemyInstanceForLevel` уже учитывает эти множители в симуляциях.
### Калибровка без полного перебора ### Калибровка без полного перебора
Выберите типичных монстров (`-enemy-type`, `-gear-check-level-*`) и итерируйте **JSON оверлей**, пока `-gear-check` не проходит; затем зафиксируйте изменения миграцией. Глобальные множители редкости остаются в `runtime_config` / `ScalePrimary`. Выберите типичных монстров (`-enemy-type`, `-gear-check-level-*`) и итерируйте **JSON оверлей**, пока `-gear-check` не проходит; затем зафиксируйте изменения миграцией. Глобальные множители редкости остаются в `runtime_config` / `ScalePrimary`.

@ -230,6 +230,13 @@ AutoHero — это idle/incremental RPG с изометрическим вид
Герой `L45` без временных баффов и без сильной экипировки остаётся относительно плоским по числам; основной рост mid/late-game должен ощущаться через экипировку, баффы и редкие уровни. Герой `L45` без временных баффов и без сильной экипировки остаётся относительно плоским по числам; основной рост mid/late-game должен ощущаться через экипировку, баффы и редкие уровни.
### 3.6 Множители экземпляра монстра при встрече
После расчёта `MaxHP` / `Attack` / `Defense` экземпляра из шаблона и `levelDelta` (см. `BuildEnemyInstanceForLevel`, `runtime_config`):
- **`enemyEncounterStatMultiplier`** (по умолчанию `1.2`) — умножает `MaxHP`, `HP`, `Attack`, `Defense` у всех боевых экземпляров монстров. Награды XP/Gold, скорость атаки врага и прочие поля шаблона не меняются.
- **`enemyStatMultiplierVsUnequippedHero`** (по умолчанию `0.75`) — если у героя нет ни одного надетого предмета в карте экипировки, те же боевые поля врага дополнительно умножаются на этот коэффициент (встречи легче для полностью неэкипированного героя).
--- ---
## 4. 🧟 Враги (Enemy Design) ## 4. 🧟 Враги (Enemy Design)
@ -484,6 +491,8 @@ primaryOut = round( basePrimary × L(ilvl) × M(rarity) )
`basePrimary` — целое из каталога для семейства предмета на «эталоне» `ilvl = 1`, `Common`. `basePrimary` — целое из каталога для семейства предмета на «эталоне» `ilvl = 1`, `Common`.
В Postgres денормализованное поле `gear.primary_stat` на строке каталога пересчитывается миграциями по той же логике `L(ilvl) × M(rarity)` (и детерминированный `tol` по `id` строки, см. `000012_gear_balance_overlay.sql`). Миграция **`000027_gear_encounter_balance.sql`** ослабляет каталог: база оружия (`slot = main_hand`) умножается на **0.85**, база предметов с `stat_type` в **`defense`** или **`mixed`** — на **0.70**, затем `primary_stat` пересчитывается заново.
**Свойство баланса:** при данной кривой `ilvl` **уровень предмета даёт более сильный вклад**, чем раньше, а редкость заметно усиливает предмет **внутри одного уровня**. Пример (без привязки к слоту, `basePrimary = 10`): **Свойство баланса:** при данной кривой `ilvl` **уровень предмета даёт более сильный вклад**, чем раньше, а редкость заметно усиливает предмет **внутри одного уровня**. Пример (без привязки к слоту, `basePrimary = 10`):
| Конфигурация | Вычисление | `primaryOut` | | Конфигурация | Вычисление | `primaryOut` |

Loading…
Cancel
Save