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