diff --git a/backend/internal/game/balance_monte_carlo.go b/backend/internal/game/balance_monte_carlo.go index f2ee461..0b6bd69 100644 --- a/backend/internal/game/balance_monte_carlo.go +++ b/backend/internal/game/balance_monte_carlo.go @@ -56,7 +56,7 @@ func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile Ref enemy = firstEnemyForBalance(level) case BalanceEnemyMixedSpawn: pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001)) - enemy = PickEnemyForLevelWithRNG(level, pickRNG) + enemy = PickEnemyForLevelWithRNG(level, pickRNG, hero) default: enemy = firstEnemyForBalance(level) } diff --git a/backend/internal/game/balance_reference.go b/backend/internal/game/balance_reference.go index 4f5304e..22a31e4 100644 --- a/backend/internal/game/balance_reference.go +++ b/backend/internal/game/balance_reference.go @@ -62,18 +62,21 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra } var wRarity, aRarity model.Rarity - var refGearBase int + var refWeaponBase, refArmorBase int switch profile { case ReferenceGearBaseline: wRarity, aRarity = model.RarityCommon, model.RarityCommon - refGearBase = 7 + refWeaponBase = 6 // post–gear-nerf weapon base (~7×0.85) + refArmorBase = 3 // post–gear-nerf chest (~4×0.7 for medium) case ReferenceGearMax: wRarity, aRarity = model.RarityLegendary, model.RarityLegendary - refGearBase = 7 + refWeaponBase = 6 + refArmorBase = 3 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 - refGearBase = 12 + refWeaponBase = 10 // ~12×0.85 + refArmorBase = 8 // ~12×0.7 } if profile == ReferenceGearBaseline || profile == ReferenceGearMax { @@ -89,7 +92,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra } } if h.Gear[model.SlotMainHand] == nil { - wPrimary := model.ScalePrimary(refGearBase, wIlvl, wRarity) + wPrimary := model.ScalePrimary(refWeaponBase, wIlvl, wRarity) h.Gear[model.SlotMainHand] = &model.GearItem{ Slot: model.SlotMainHand, FormID: "gear.form.main_hand.sword", @@ -97,7 +100,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra Subtype: "sword", Rarity: wRarity, Ilvl: wIlvl, - BasePrimary: refGearBase, + BasePrimary: refWeaponBase, PrimaryStat: wPrimary, StatType: "attack", SpeedModifier: 1.0, @@ -105,7 +108,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra } } if h.Gear[model.SlotChest] == nil { - aPrimary := model.ScalePrimary(refGearBase, aIlvl, aRarity) + aPrimary := model.ScalePrimary(refArmorBase, aIlvl, aRarity) h.Gear[model.SlotChest] = &model.GearItem{ Slot: model.SlotChest, FormID: "gear.form.chest.medium", @@ -113,7 +116,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra Subtype: "medium", Rarity: aRarity, Ilvl: aIlvl, - BasePrimary: refGearBase, + BasePrimary: refArmorBase, PrimaryStat: aPrimary, StatType: "defense", SpeedModifier: 1.0, diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 22d948a..ca342a1 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -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. if hm.State == model.StateFighting { if _, exists := e.combats[hero.ID]; !exists { - en := PickEnemyForLevel(hero.Level) + en := PickEnemyForHero(hero) if en.Slug != "" { e.startCombatLocked(hm.Hero, &en) } else { diff --git a/backend/internal/game/engine_bootstrap.go b/backend/internal/game/engine_bootstrap.go index dc700c1..93a9fc3 100644 --- a/backend/internal/game/engine_bootstrap.go +++ b/backend/internal/game/engine_bootstrap.go @@ -64,7 +64,7 @@ func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage. hm.SyncToHero() if hm.State == model.StateFighting { if _, exists := e.combats[h.ID]; !exists { - en := PickEnemyForLevel(h.Level) + en := PickEnemyForHero(h) if en.Slug != "" { e.startCombatLocked(hm.Hero, &en) } else { diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index a217e96..66e091c 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -1109,7 +1109,7 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { - e := PickEnemyForLevel(hm.Hero.Level) + e := PickEnemyForHero(hm.Hero) return true, e, true } return false, model.Enemy{}, true @@ -1877,7 +1877,7 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { - e := PickEnemyForLevel(hm.Hero.Level) + e := PickEnemyForHero(hm.Hero) return true, e, true } return false, model.Enemy{}, true diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 66d49db..5314913 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -41,7 +41,7 @@ type OfflineSimulator struct { isPaused func() bool // skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // so the same hero is not simulated twice. - skipIfLive func(heroID int64) bool + skipIfLive func(heroID int64) bool digestStore *storage.OfflineDigestStore } @@ -419,7 +419,7 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene if encounterEnemy != nil { enemy = *encounterEnemy } else { - enemy = PickEnemyForLevel(hero.Level) + enemy = PickEnemyForHero(hero) } if rewardDeps.InTown == nil && g != nil { 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. +// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier). func PickEnemyForLevel(level int) model.Enemy { - candidates := enemyCandidatesForHeroLevel(level) - if len(candidates) == 0 { + return pickEnemyForHeroLevel(nil, level, nil) +} + +// 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{} } - picked := candidates[rand.Intn(len(candidates))] - return buildEnemyInstance(picked, level, nil) + return pickEnemyForHeroLevel(hero, hero.Level, nil) } // PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims). -func PickEnemyForLevelWithRNG(level int, rng *rand.Rand) model.Enemy { - if rng == nil { - return PickEnemyForLevel(level) - } +// Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil). +func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy { + return pickEnemyForHeroLevel(hero, level, rng) +} + +func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy { candidates := enemyCandidatesForHeroLevel(level) if len(candidates) == 0 { return model.Enemy{} } - picked := candidates[rng.Intn(len(candidates))] - return buildEnemyInstance(picked, level, rng) + var picked model.Enemy + 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 { @@ -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.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 } +// 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 { if v < 0 { return -v diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index 9a46381..e1f741d 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/denisovdennis/autohero/internal/model" + "github.com/denisovdennis/autohero/internal/tuning" ) 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) { tests := []struct { level int diff --git a/backend/internal/game/progression_sim.go b/backend/internal/game/progression_sim.go index ba48d61..65da756 100644 --- a/backend/internal/game/progression_sim.go +++ b/backend/internal/game/progression_sim.go @@ -364,7 +364,7 @@ func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds floa hero := CloneHeroForCombatSim(baseHero) pickRNG := rand.New(rand.NewSource(seed + 11_111)) - enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG) + enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG, hero) survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{ TickRate: 100 * time.Millisecond, diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 74b5b2a..3075b37 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2301,7 +2301,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req return } - enemy := game.PickEnemyForLevel(hm.Hero.Level) + enemy := game.PickEnemyForHero(hm.Hero) if enemy.Slug == "" || enemy.MaxHP <= 0 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"}) 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". enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil) } + game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy) combatStart := game.CombatSimDeterministicStart hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart) diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 84cc83f..63a0d0b 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -454,7 +454,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) { return } - enemy := pickEnemyForLevel(hero.Level) + enemy := pickEnemyForHero(hero) h.encounterMu.Lock() h.lastCombatEncounterAt[hero.ID] = now h.encounterMu.Unlock() @@ -493,9 +493,9 @@ func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool return false } -// pickEnemyForLevel delegates to the canonical implementation in the game package. -func pickEnemyForLevel(level int) model.Enemy { - return game.PickEnemyForLevel(level) +// pickEnemyForHero delegates to the canonical implementation in the game package. +func pickEnemyForHero(hero *model.Hero) model.Enemy { + return game.PickEnemyForHero(hero) } // tryAutoEquipGear uses the in-memory combat rating comparison to decide whether diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 8c1bdc9..98a4d2c 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -110,6 +110,10 @@ type Values struct { EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyChainEveryN int64 `json:"enemyChainEveryN"` 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"` DebuffProcPoison float64 `json:"debuffProcPoison"` @@ -324,6 +328,8 @@ func DefaultValues() Values { EnemyBurstMultiplier: 1.5, EnemyChainEveryN: 6, EnemyChainMultiplier: 3.0, + EnemyEncounterStatMultiplier: 1.2, + EnemyStatMultiplierVsUnequippedHero: 0.75, DebuffProcBurn: 0.18, DebuffProcPoison: 0.10, DebuffProcSlow: 0.25, diff --git a/backend/migrations/000027_gear_encounter_balance.sql b/backend/migrations/000027_gear_encounter_balance.sql new file mode 100644 index 0000000..dcac89e --- /dev/null +++ b/backend/migrations/000027_gear_encounter_balance.sql @@ -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; diff --git a/docs/balanceall.md b/docs/balanceall.md index 2ba7433..87cc445 100644 --- a/docs/balanceall.md +++ b/docs/balanceall.md @@ -67,6 +67,8 @@ go run ./cmd/balanceall -gear-overlay ./cmd/balanceall/testdata/gear_overlay_bal Команда завершает работу после вывода 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`. diff --git a/docs/specification.md b/docs/specification.md index 763bb3c..d616b61 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -230,6 +230,13 @@ AutoHero — это idle/incremental RPG с изометрическим вид Герой `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) @@ -484,6 +491,8 @@ primaryOut = round( basePrimary × L(ilvl) × M(rarity) ) `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`): | Конфигурация | Вычисление | `primaryOut` |