diff --git a/admin-web/index.html b/admin-web/index.html index 9e6c8c7..fa12e4c 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -278,6 +278,10 @@ luckBuffMultiplier: "Множитель влияния удачи на лут.", minAttackIntervalMs: "Минимальный интервал между атаками (нижняя граница скорости), мс.", combatPaceMultiplier: "Множитель к интервалу атак в бою; чем выше — тем реже удары (длиннее паузы).", + enemyAttackIntervalMultiplier: "Только враги: множитель к их интервалу атак (герой без изменений). Выше — реже, но обычно паруют с enemyCombatDamageScale.", + enemyCombatDamageScale: "Масштаб урона врага по герою (входящий урон за удар).", + enemyCombatDamageRollMin: "Мин. ролл входящего урона врага.", + enemyCombatDamageRollMax: "Макс. ролл входящего урона врага.", potionHealPercent: "Доля MaxHP, восстанавливаемая зельем.", potionAutoUseThreshold: "При какой доле MaxHP автоиспользовать зелье (если включено).", reviveHpPercent: "Доля MaxHP после воскрешения.", @@ -454,6 +458,10 @@ agilityCoef: "hero_combat", maxAttackSpeed: "hero_combat", minAttackSpeed: "hero_combat", + enemyAttackIntervalMultiplier: "enemy_combat", + enemyCombatDamageScale: "enemy_combat", + enemyCombatDamageRollMin: "enemy_combat", + enemyCombatDamageRollMax: "enemy_combat", enemyDodgeChance: "enemy_combat", enemyCriticalMinChance: "enemy_combat", enemyCritChanceCap: "enemy_combat", diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index c5911e7..1da9e8b 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -325,29 +325,31 @@ func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) { func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int { totalDmg := 0 - for _, ad := range hero.Debuffs { + for i := range hero.Debuffs { + ad := &hero.Debuffs[i] if ad.IsExpired(now) { continue } switch ad.Debuff.Type { case model.DebuffPoison: - // -2% HP/sec, scaled by tick duration. - dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) - if dmg < 1 { - dmg = 1 + // % max HP per second, scaled by tick duration; fractional damage carries over ticks. + dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder + dmg := int(dmgFloat) + ad.DotRemainder = dmgFloat - float64(dmg) + if dmg > 0 { + hero.HP -= dmg + totalDmg += dmg } - hero.HP -= dmg - totalDmg += dmg case model.DebuffBurn: - // -3% HP/sec, scaled by tick duration. - dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) - if dmg < 1 { - dmg = 1 + dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder + dmg := int(dmgFloat) + ad.DotRemainder = dmgFloat - float64(dmg) + if dmg > 0 { + hero.HP -= dmg + totalDmg += dmg } - hero.HP -= dmg - totalDmg += dmg } } diff --git a/backend/internal/game/combat_sim.go b/backend/internal/game/combat_sim.go index 56fca0d..57b66c7 100644 --- a/backend/internal/game/combat_sim.go +++ b/backend/internal/game/combat_sim.go @@ -25,8 +25,19 @@ type CombatSimOptions struct { // ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine. // It mutates hero and enemy until one side dies, returning whether the hero survived. func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool { + survived, _ := resolveCombatToEnd(hero, enemy, start, opts) + return survived +} + +// ResolveCombatToEndWithDuration is like ResolveCombatToEnd but also returns simulated combat +// elapsed time (last event time minus start), using the same timeline as the online engine. +func ResolveCombatToEndWithDuration(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) { + return resolveCombatToEnd(hero, enemy, start, opts) +} + +func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) { if hero == nil || enemy == nil { - return false + return false, 0 } tickRate := opts.TickRate if tickRate <= 0 { @@ -35,7 +46,7 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o now := start heroNext := now.Add(attackInterval(hero.EffectiveSpeed())) - enemyNext := now.Add(attackInterval(enemy.Speed)) + enemyNext := now.Add(attackIntervalEnemy(enemy.Speed)) nextTick := now.Add(tickRate) lastTickAt := now var regenRemainder float64 @@ -63,7 +74,7 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o lastTickAt = now if CheckDeath(hero, now) { hero.HP = 0 - return false + return false, now.Sub(start) } } nextTick = nextTick.Add(tickRate) @@ -73,7 +84,7 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o if !heroNext.After(enemyNext) && now.Equal(heroNext) { ProcessAttack(hero, enemy, now) if !enemy.IsAlive() { - return true + return true, now.Sub(start) } heroNext = now.Add(attackInterval(hero.EffectiveSpeed())) continue @@ -83,16 +94,17 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o ProcessEnemyAttack(hero, enemy, now) if CheckDeath(hero, now) { hero.HP = 0 - return false + return false, now.Sub(start) } if opts.AutoUsePotion != nil { _ = opts.AutoUsePotion(hero, now) } - enemyNext = now.Add(attackInterval(enemy.Speed)) + enemyNext = now.Add(attackIntervalEnemy(enemy.Speed)) } } - return hero.HP > 0 && enemy.IsAlive() == false + win := hero.HP > 0 && enemy.IsAlive() == false + return win, now.Sub(start) } // OfflineAutoPotionHook is a low-probability offline-only potion usage policy. diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 7f9923a..be3af2b 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -182,7 +182,7 @@ func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Durat hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier)) } if ena.Before(now) { - ena = now.Add(attackInterval(cs.Enemy.Speed)) + ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) } cs.HeroNextAttack = hna cs.EnemyNextAttack = ena @@ -943,7 +943,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { Hero: hero, Enemy: *enemy, HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())), - EnemyNextAttack: now.Add(attackInterval(enemy.Speed)), + EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)), StartedAt: now, LastTickAt: now, } @@ -1426,7 +1426,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { } // Reschedule enemy's next attack. - cs.EnemyNextAttack = now.Add(attackInterval(cs.Enemy.Speed)) + cs.EnemyNextAttack = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) heap.Push(&e.queue, &model.AttackEvent{ NextAttackAt: cs.EnemyNextAttack, IsHero: false, @@ -1764,6 +1764,13 @@ func attackInterval(speed float64) time.Duration { return interval } +// attackIntervalEnemy applies EnemyAttackIntervalMultiplier only to monsters (slower, heavier swings vs hero cadence). +func attackIntervalEnemy(speed float64) time.Duration { + base := attackInterval(speed) + m := tuning.EffectiveEnemyAttackIntervalMultiplier() + return time.Duration(float64(base) * m) +} + // enemyToInfo converts a model.Enemy to the WS payload info struct. func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { return model.CombatEnemyInfo{ diff --git a/backend/internal/game/engine_test.go b/backend/internal/game/engine_test.go index 731b631..5320444 100644 --- a/backend/internal/game/engine_test.go +++ b/backend/internal/game/engine_test.go @@ -25,3 +25,13 @@ func TestAttackIntervalForNormalSpeed(t *testing.T) { t.Fatalf("expected %s, got %s", want, got) } } + +func TestAttackIntervalEnemySlowerThanHero(t *testing.T) { + base := attackInterval(2.0) + got := attackIntervalEnemy(2.0) + m := tuning.EffectiveEnemyAttackIntervalMultiplier() + want := time.Duration(float64(base) * m) + if got != want { + t.Fatalf("enemy interval %s, want %s (base %s × m=%.3f)", got, want, base, m) + } +} diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 9467a62..23f60db 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -416,6 +416,34 @@ func PickEnemyForLevel(level int) model.Enemy { return ScaleEnemyTemplate(picked, level) } +// 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) + } + candidates := make([]model.Enemy, 0, len(model.EnemyTemplates)) + for _, t := range model.EnemyTemplates { + if level >= t.MinLevel && level <= t.MaxLevel { + candidates = append(candidates, t) + } + } + if len(candidates) == 0 { + highestMin := 0 + for _, t := range model.EnemyTemplates { + if t.MinLevel > highestMin { + highestMin = t.MinLevel + } + } + for _, t := range model.EnemyTemplates { + if t.MinLevel >= highestMin { + candidates = append(candidates, t) + } + } + } + picked := candidates[rng.Intn(len(candidates))] + return ScaleEnemyTemplate(picked, level) +} + // ScaleEnemyTemplate applies band-based level scaling to stats and rewards. // Exported for reuse across handler and offline simulation. func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy { diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 448f083..88fc309 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2496,6 +2496,80 @@ func (h *AdminHandler) ReloadBuffDebuffConfig(w http.ResponseWriter, r *http.Req }) } +// ContentListEnemies returns all rows from the enemies table. +// GET /admin/content/enemies +func (h *AdminHandler) ContentListEnemies(w http.ResponseWriter, r *http.Request) { + cs := storage.NewContentStore(h.pool) + rows, err := cs.ListEnemyRows(r.Context()) + if err != nil { + h.logger.Error("list enemies", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to list enemies", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "enemies": rows, + }) +} + +// ContentUpdateEnemy overwrites one enemy template by type and hot-reloads in-memory templates. +// PUT /admin/content/enemies/{enemyType} +func (h *AdminHandler) ContentUpdateEnemy(w http.ResponseWriter, r *http.Request) { + typ := strings.TrimSpace(chi.URLParam(r, "enemyType")) + if typ == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing enemyType"}) + return + } + var e model.Enemy + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + e.Type = model.EnemyType(typ) + e.HP = e.MaxHP + cs := storage.NewContentStore(h.pool) + if err := cs.UpdateEnemyByType(r.Context(), typ, e); err != nil { + h.logger.Error("update enemy", "type", typ, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + m, err := cs.LoadEnemyTemplates(r.Context()) + if err != nil { + h.logger.Error("reload enemy templates after update", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "saved but failed to reload templates", + }) + return + } + model.SetEnemyTemplates(m) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "count": len(m), + }) +} + +// ReloadEnemyTemplates loads enemies from DB into model.EnemyTemplates (hot load). +// POST /admin/content/enemies/reload +func (h *AdminHandler) ReloadEnemyTemplates(w http.ResponseWriter, r *http.Request) { + cs := storage.NewContentStore(h.pool) + m, err := cs.LoadEnemyTemplates(r.Context()) + if err != nil { + h.logger.Error("load enemy templates", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load enemies", + }) + return + } + model.SetEnemyTemplates(m) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reloaded", + "count": len(m), + }) +} + // ── Helpers ───────────────────────────────────────────────────────── func parseHeroID(r *http.Request) (int64, error) { diff --git a/backend/internal/model/buff.go b/backend/internal/model/buff.go index 1a332f9..05dceb2 100644 --- a/backend/internal/model/buff.go +++ b/backend/internal/model/buff.go @@ -96,6 +96,8 @@ type ActiveDebuff struct { Debuff Debuff `json:"debuff"` AppliedAt time.Time `json:"appliedAt"` ExpiresAt time.Time `json:"expiresAt"` + // DotRemainder accumulates fractional poison/burn damage between ticks (not persisted). + DotRemainder float64 `json:"-"` } // IsExpired returns true if the debuff has expired relative to the given time. diff --git a/backend/internal/model/buff_catalog.go b/backend/internal/model/buff_catalog.go index 6d7e438..9660049 100644 --- a/backend/internal/model/buff_catalog.go +++ b/backend/internal/model/buff_catalog.go @@ -99,7 +99,7 @@ func seedDebuffMap() map[DebuffType]Debuff { }, DebuffBurn: { Type: DebuffBurn, Name: "Burn", - Duration: 40 * time.Second, Magnitude: 0.03, + Duration: 40 * time.Second, Magnitude: 0.018, }, DebuffStun: { Type: DebuffStun, Name: "Stun", diff --git a/backend/internal/model/enemy.go b/backend/internal/model/enemy.go index 560feff..21c437a 100644 --- a/backend/internal/model/enemy.go +++ b/backend/internal/model/enemy.go @@ -75,47 +75,47 @@ var EnemyTemplates = map[EnemyType]Enemy{ // --- Basic enemies --- EnemyWolf: { Type: EnemyWolf, Name: "Forest Wolf", - MaxHP: 45, Attack: 9, Defense: 4, Speed: 1.8, CritChance: 0.05, + MaxHP: 60, Attack: 11, Defense: 5, Speed: 1.8, CritChance: 0.05, MinLevel: 1, MaxLevel: 5, XPReward: 1, GoldReward: 1, }, EnemyBoar: { Type: EnemyBoar, Name: "Wild Boar", - MaxHP: 65, Attack: 18, Defense: 7, Speed: 0.8, CritChance: 0.08, + MaxHP: 74, Attack: 19, Defense: 8, Speed: 0.8, CritChance: 0.08, MinLevel: 2, MaxLevel: 6, XPReward: 1, GoldReward: 1, }, EnemyZombie: { Type: EnemyZombie, Name: "Rotting Zombie", - MaxHP: 95, Attack: 16, Defense: 7, Speed: 0.5, + MaxHP: 108, Attack: 17, Defense: 8, Speed: 0.5, MinLevel: 3, MaxLevel: 8, XPReward: 1, GoldReward: 1, SpecialAbilities: []SpecialAbility{AbilityPoison}, }, EnemySpider: { Type: EnemySpider, Name: "Cave Spider", - MaxHP: 38, Attack: 16, Defense: 3, Speed: 2.0, CritChance: 0.15, + MaxHP: 44, Attack: 17, Defense: 4, Speed: 2.0, CritChance: 0.15, MinLevel: 4, MaxLevel: 9, XPReward: 1, GoldReward: 1, SpecialAbilities: []SpecialAbility{AbilityCritical}, }, EnemyOrc: { Type: EnemyOrc, Name: "Orc Warrior", - MaxHP: 110, Attack: 21, Defense: 12, Speed: 1.0, CritChance: 0.05, + MaxHP: 118, Attack: 22, Defense: 13, Speed: 1.0, CritChance: 0.05, MinLevel: 5, MaxLevel: 12, XPReward: 1, GoldReward: 1, SpecialAbilities: []SpecialAbility{AbilityBurst}, }, EnemySkeletonArcher: { Type: EnemySkeletonArcher, Name: "Skeleton Archer", - MaxHP: 90, Attack: 24, Defense: 10, Speed: 1.3, CritChance: 0.06, + MaxHP: 96, Attack: 24, Defense: 11, Speed: 1.3, CritChance: 0.06, MinLevel: 6, MaxLevel: 14, XPReward: 1, GoldReward: 1, SpecialAbilities: []SpecialAbility{AbilityDodge}, }, EnemyBattleLizard: { Type: EnemyBattleLizard, Name: "Battle Lizard", - MaxHP: 140, Attack: 24, Defense: 18, Speed: 0.7, CritChance: 0.03, + MaxHP: 148, Attack: 25, Defense: 19, Speed: 0.7, CritChance: 0.03, MinLevel: 7, MaxLevel: 15, XPReward: 1, GoldReward: 1, SpecialAbilities: []SpecialAbility{AbilityRegen}, @@ -124,42 +124,42 @@ var EnemyTemplates = map[EnemyType]Enemy{ // --- Elite enemies --- EnemyFireDemon: { Type: EnemyFireDemon, Name: "Fire Demon", - MaxHP: 230, Attack: 34, Defense: 20, Speed: 1.2, CritChance: 0.10, + MaxHP: 128, Attack: 24, Defense: 13, Speed: 1.2, CritChance: 0.10, MinLevel: 10, MaxLevel: 20, XPReward: 1, GoldReward: 1, IsElite: true, SpecialAbilities: []SpecialAbility{AbilityBurn}, }, EnemyIceGuardian: { Type: EnemyIceGuardian, Name: "Ice Guardian", - MaxHP: 280, Attack: 32, Defense: 28, Speed: 0.7, CritChance: 0.04, + MaxHP: 245, Attack: 28, Defense: 26, Speed: 0.7, CritChance: 0.04, MinLevel: 12, MaxLevel: 22, XPReward: 1, GoldReward: 1, IsElite: true, SpecialAbilities: []SpecialAbility{AbilityIceSlow}, }, EnemySkeletonKing: { Type: EnemySkeletonKing, Name: "Skeleton King", - MaxHP: 420, Attack: 48, Defense: 30, Speed: 0.9, CritChance: 0.08, + MaxHP: 365, Attack: 42, Defense: 28, Speed: 0.9, CritChance: 0.08, MinLevel: 15, MaxLevel: 25, XPReward: 1, GoldReward: 1, IsElite: true, SpecialAbilities: []SpecialAbility{AbilityRegen, AbilitySummon}, }, EnemyWaterElement: { Type: EnemyWaterElement, Name: "Water Element", - MaxHP: 520, Attack: 42, Defense: 24, Speed: 0.8, CritChance: 0.05, + MaxHP: 455, Attack: 37, Defense: 22, Speed: 0.8, CritChance: 0.05, MinLevel: 18, MaxLevel: 28, XPReward: 2, GoldReward: 1, IsElite: true, SpecialAbilities: []SpecialAbility{AbilitySlow}, }, EnemyForestWarden: { Type: EnemyForestWarden, Name: "Forest Warden", - MaxHP: 700, Attack: 38, Defense: 40, Speed: 0.5, CritChance: 0.03, + MaxHP: 610, Attack: 34, Defense: 37, Speed: 0.5, CritChance: 0.03, MinLevel: 20, MaxLevel: 30, XPReward: 2, GoldReward: 1, IsElite: true, SpecialAbilities: []SpecialAbility{AbilityRegen}, }, EnemyLightningTitan: { Type: EnemyLightningTitan, Name: "Lightning Titan", - MaxHP: 650, Attack: 56, Defense: 30, Speed: 1.5, CritChance: 0.12, + MaxHP: 565, Attack: 49, Defense: 28, Speed: 1.5, CritChance: 0.12, MinLevel: 25, MaxLevel: 35, XPReward: 3, GoldReward: 2, IsElite: true, SpecialAbilities: []SpecialAbility{AbilityStun, AbilityChainLightning}, diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go index 1482711..0ce8d04 100644 --- a/backend/internal/model/excursion.go +++ b/backend/internal/model/excursion.go @@ -13,16 +13,6 @@ const ( ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible) ) -// RestKind discriminates the context of a StateResting period. -type RestKind string - -const ( - RestKindNone RestKind = "" - RestKindTown RestKind = "town" - RestKindRoadside RestKind = "roadside" - RestKindAdventureInline RestKind = "adventure_inline" -) - // ExcursionSession holds the live state of an active mini-adventure (off-road excursion). // When Phase == ExcursionNone the session is inactive and all other fields are zero-valued. type ExcursionSession struct { diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 0e1b7a9..025a072 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -126,7 +126,11 @@ func (h *Hero) LevelUp() bool { // v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3). cfg := tuning.Get() if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 { - h.MaxHP += 1 + h.Constitution/6 + hpBase := cfg.LevelUpHpBase + if hpBase <= 0 { + hpBase = 1 + } + h.MaxHP += hpBase + h.Constitution/6 } if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { h.Attack++ diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go index a9c7d1e..70a015b 100644 --- a/backend/internal/model/hero_test.go +++ b/backend/internal/model/hero_test.go @@ -285,19 +285,19 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) { t.Run("L30", func(t *testing.T) { h := snap(30) - if h.MaxHP != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 { + if h.MaxHP != 170 || h.Attack != 17 || h.Defense != 11 || h.Strength != 3 { 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) != 7 { + if h.EffectiveAttackAt(now) != 23 || h.EffectiveDefenseAt(now) != 14 { t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) } }) t.Run("L45", func(t *testing.T) { h := snap(45) - if h.MaxHP != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 { + if h.MaxHP != 210 || h.Attack != 21 || h.Defense != 14 || h.Strength != 4 { 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) != 7 { + if h.EffectiveAttackAt(now) != 29 || h.EffectiveDefenseAt(now) != 18 { t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) } }) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 78958fe..c4fbd3d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -129,6 +129,9 @@ func New(deps Deps) *chi.Mux { r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig) r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig) r.Post("/buff-debuff-config/reload", adminH.ReloadBuffDebuffConfig) + r.Get("/content/enemies", adminH.ContentListEnemies) + r.Put("/content/enemies/{enemyType}", adminH.ContentUpdateEnemy) + r.Post("/content/enemies/reload", adminH.ReloadEnemyTemplates) r.Get("/payments", adminH.ListPayments) r.Get("/payments/{paymentId}", adminH.GetPayment) r.Post("/payments/set-webhook", paymentsH.SetWebhook) diff --git a/backend/internal/storage/content_store.go b/backend/internal/storage/content_store.go index d26d3e3..51461a8 100644 --- a/backend/internal/storage/content_store.go +++ b/backend/internal/storage/content_store.go @@ -55,6 +55,88 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT return out, nil } +// EnemyRow is one row from the enemies table (admin / tooling). +type EnemyRow struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + HP int `json:"hp"` + MaxHP int `json:"maxHp"` + Attack int `json:"attack"` + Defense int `json:"defense"` + Speed float64 `json:"speed"` + CritChance float64 `json:"critChance"` + MinLevel int `json:"minLevel"` + MaxLevel int `json:"maxLevel"` + XPReward int64 `json:"xpReward"` + GoldReward int64 `json:"goldReward"` + SpecialAbilities []string `json:"specialAbilities"` + IsElite bool `json:"isElite"` +} + +// ListEnemyRows returns all enemy templates ordered by min_level, type. +func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, type, name, hp, max_hp, attack, defense, speed, crit_chance, + min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite + FROM enemies + ORDER BY min_level, type + `) + if err != nil { + return nil, fmt.Errorf("list enemies: %w", err) + } + defer rows.Close() + + var out []EnemyRow + for rows.Next() { + var r EnemyRow + if err := rows.Scan( + &r.ID, &r.Type, &r.Name, &r.HP, &r.MaxHP, &r.Attack, &r.Defense, &r.Speed, &r.CritChance, + &r.MinLevel, &r.MaxLevel, &r.XPReward, &r.GoldReward, &r.SpecialAbilities, &r.IsElite, + ); err != nil { + return nil, fmt.Errorf("scan enemy row: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// UpdateEnemyByType persists one template and sets hp = max_hp = MaxHP from e. +func (s *ContentStore) UpdateEnemyByType(ctx context.Context, typ string, e model.Enemy) error { + abilities := make([]string, 0, len(e.SpecialAbilities)) + for _, a := range e.SpecialAbilities { + abilities = append(abilities, string(a)) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE enemies SET + name = $2, + hp = $3, + max_hp = $4, + attack = $5, + defense = $6, + speed = $7, + crit_chance = $8, + min_level = $9, + max_level = $10, + xp_reward = $11, + gold_reward = $12, + special_abilities = $13::text[], + is_elite = $14 + WHERE type = $1 + `, typ, e.Name, e.MaxHP, e.MaxHP, e.Attack, e.Defense, e.Speed, e.CritChance, + e.MinLevel, e.MaxLevel, e.XPReward, e.GoldReward, abilities, e.IsElite) + if err != nil { + return fmt.Errorf("update enemy: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no enemy row with type %q", typ) + } + return nil +} + func normalizeEquipmentSlot(raw string) model.EquipmentSlot { v := strings.TrimSpace(strings.ToLower(raw)) v = strings.TrimPrefix(v, "gear.slot.") diff --git a/backend/internal/tuning/combat_defaults.go b/backend/internal/tuning/combat_defaults.go index 0c415a4..cbbfdc4 100644 --- a/backend/internal/tuning/combat_defaults.go +++ b/backend/internal/tuning/combat_defaults.go @@ -1,17 +1,22 @@ package tuning // Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax). +// Kept in proportion to combatPaceMultiplier vs legacy (28): same incoming DPS when attack intervals shrink. +// DefaultEnemyAttackIntervalMultiplier stretches only enemy swing spacing; DefaultEnemyCombatDamageScale is paired so incoming DPS stays in the same ballpark. const ( - DefaultEnemyCombatDamageScale = 1.0 - DefaultEnemyCombatDamageRollMin = 0.8 - DefaultEnemyCombatDamageRollMax = 1.0 + DefaultEnemyAttackIntervalMultiplier = 1.5 // enemyAttackIntervalMultiplier + DefaultEnemyCombatDamageScale = 1.0 // enemyCombatDamageScale + DefaultEnemyCombatDamageRollMin = 0.82 + DefaultEnemyCombatDamageRollMax = 1.0 ) // Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below). +// Hero attack intervals are often multi-second; regen accumulates over the full gap — keep rates low +// so net DPS stays positive (e.g. 0.003 ≈ 0.3%/s → ~3% MaxHP over a 10s gap). // Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required. const ( - DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault - DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing - DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden - DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard + DefaultEnemyRegenDefault = 0.006 // enemyRegenDefault + DefaultEnemyRegenSkeletonKing = 0.0015 // enemyRegenSkeletonKing + DefaultEnemyRegenForestWarden = 0.003 // enemyRegenForestWarden + DefaultEnemyRegenBattleLizard = 0.004 // enemyRegenBattleLizard ) diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 0f49eab..4fc2792 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -95,6 +95,8 @@ type Values struct { EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` + // EnemyAttackIntervalMultiplier applies only to enemy attack spacing (hero cadence unchanged). Pair with enemy damage scale for similar incoming DPS. + EnemyAttackIntervalMultiplier float64 `json:"enemyAttackIntervalMultiplier"` EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` @@ -137,6 +139,8 @@ type Values struct { XPCurveLateScale float64 `json:"xpCurveLateScale"` LevelUpHPEvery int64 `json:"levelUpHpEvery"` + // LevelUpHpBase is added to MaxHP together with Constitution/6 when LevelUpHPEvery fires (spec §3.3 cadence). + LevelUpHpBase int `json:"levelUpHpBase"` LevelUpATKEvery int64 `json:"levelUpAtkEvery"` LevelUpDEFEvery int64 `json:"levelUpDefEvery"` LevelUpSTREvery int64 `json:"levelUpStrEvery"` @@ -286,13 +290,15 @@ func DefaultValues() Values { NPCCostNearbyRadius: 3.0, QuestOffersPerNPC: 2, QuestOfferRefreshHours: 2, - CombatDamageScale: 0.35, + // combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length. + CombatDamageScale: 0.216, CombatDamageRollMin: 0.60, CombatDamageRollMax: 1.10, EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, - EnemyDodgeChance: 0.20, + EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier, + EnemyDodgeChance: 0.14, EnemyCriticalMinChance: 0.10, EnemyCritChanceCap: 0.20, HeroCritChanceCap: 0.12, @@ -316,7 +322,7 @@ func DefaultValues() Values { SummonDamageDivisor: 4, LuckBuffMultiplier: 1.75, MinAttackIntervalMs: 250, - CombatPaceMultiplier: 5, + CombatPaceMultiplier: 14, PotionHealPercent: 0.30, PotionAutoUseThreshold: 0.30, ReviveHpPercent: 0.50, @@ -327,12 +333,13 @@ func DefaultValues() Values { XPCurveMidScale: 1.15, XPCurveLateBase: 23000, XPCurveLateScale: 1.10, - LevelUpHPEvery: 10, - LevelUpATKEvery: 30, - LevelUpDEFEvery: 30, - LevelUpSTREvery: 40, - LevelUpCONEvery: 50, - LevelUpAGIEvery: 60, + LevelUpHPEvery: 4, + LevelUpHpBase: 10, + LevelUpATKEvery: 4, + LevelUpDEFEvery: 5, + LevelUpSTREvery: 12, + LevelUpCONEvery: 14, + LevelUpAGIEvery: 20, LevelUpLUCKEvery: 100, AgilityCoef: 0.03, MaxAttackSpeed: 4.0, @@ -353,12 +360,12 @@ func DefaultValues() Values { ResurrectionRefillPriceRUB: 150, MaxRevivesFree: 1, MaxRevivesSubscriber: 2, - EnemyScaleBandHP: 0.05, - EnemyScaleOvercapHP: 0.025, - EnemyScaleBandATK: 0.035, - EnemyScaleOvercapATK: 0.018, - EnemyScaleBandDEF: 0.035, - EnemyScaleOvercapDEF: 0.018, + EnemyScaleBandHP: 0.062, + EnemyScaleOvercapHP: 0.031, + EnemyScaleBandATK: 0.044, + EnemyScaleOvercapATK: 0.024, + EnemyScaleBandDEF: 0.038, + EnemyScaleOvercapDEF: 0.020, EnemyScaleBandXP: 0.05, EnemyScaleOvercapXP: 0.03, EnemyScaleBandGold: 0.05, @@ -461,6 +468,15 @@ func EffectiveEnemyRegenBattleLizard() float64 { return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard) } +// EffectiveEnemyAttackIntervalMultiplier returns the factor applied only to enemy attack intervals (>=1 = slower enemy swings). +func EffectiveEnemyAttackIntervalMultiplier() float64 { + m := Get().EnemyAttackIntervalMultiplier + if m <= 0 { + return DefaultEnemyAttackIntervalMultiplier + } + return m +} + func Set(v Values) { current.Store(&v) }