rebalance start

master
Denis Ranneft 1 month ago
parent 0c52a369cb
commit 86ffebf26a

@ -278,6 +278,10 @@
luckBuffMultiplier: "Множитель влияния удачи на лут.", luckBuffMultiplier: "Множитель влияния удачи на лут.",
minAttackIntervalMs: "Минимальный интервал между атаками (нижняя граница скорости), мс.", minAttackIntervalMs: "Минимальный интервал между атаками (нижняя граница скорости), мс.",
combatPaceMultiplier: "Множитель к интервалу атак в бою; чем выше — тем реже удары (длиннее паузы).", combatPaceMultiplier: "Множитель к интервалу атак в бою; чем выше — тем реже удары (длиннее паузы).",
enemyAttackIntervalMultiplier: "Только враги: множитель к их интервалу атак (герой без изменений). Выше — реже, но обычно паруют с enemyCombatDamageScale.",
enemyCombatDamageScale: "Масштаб урона врага по герою (входящий урон за удар).",
enemyCombatDamageRollMin: "Мин. ролл входящего урона врага.",
enemyCombatDamageRollMax: "Макс. ролл входящего урона врага.",
potionHealPercent: "Доля MaxHP, восстанавливаемая зельем.", potionHealPercent: "Доля MaxHP, восстанавливаемая зельем.",
potionAutoUseThreshold: "При какой доле MaxHP автоиспользовать зелье (если включено).", potionAutoUseThreshold: "При какой доле MaxHP автоиспользовать зелье (если включено).",
reviveHpPercent: "Доля MaxHP после воскрешения.", reviveHpPercent: "Доля MaxHP после воскрешения.",
@ -454,6 +458,10 @@
agilityCoef: "hero_combat", agilityCoef: "hero_combat",
maxAttackSpeed: "hero_combat", maxAttackSpeed: "hero_combat",
minAttackSpeed: "hero_combat", minAttackSpeed: "hero_combat",
enemyAttackIntervalMultiplier: "enemy_combat",
enemyCombatDamageScale: "enemy_combat",
enemyCombatDamageRollMin: "enemy_combat",
enemyCombatDamageRollMax: "enemy_combat",
enemyDodgeChance: "enemy_combat", enemyDodgeChance: "enemy_combat",
enemyCriticalMinChance: "enemy_combat", enemyCriticalMinChance: "enemy_combat",
enemyCritChanceCap: "enemy_combat", enemyCritChanceCap: "enemy_combat",

@ -325,31 +325,33 @@ func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int { func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int {
totalDmg := 0 totalDmg := 0
for _, ad := range hero.Debuffs { for i := range hero.Debuffs {
ad := &hero.Debuffs[i]
if ad.IsExpired(now) { if ad.IsExpired(now) {
continue continue
} }
switch ad.Debuff.Type { switch ad.Debuff.Type {
case model.DebuffPoison: case model.DebuffPoison:
// -2% HP/sec, scaled by tick duration. // % max HP per second, scaled by tick duration; fractional damage carries over ticks.
dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder
if dmg < 1 { dmg := int(dmgFloat)
dmg = 1 ad.DotRemainder = dmgFloat - float64(dmg)
} if dmg > 0 {
hero.HP -= dmg hero.HP -= dmg
totalDmg += dmg totalDmg += dmg
}
case model.DebuffBurn: case model.DebuffBurn:
// -3% HP/sec, scaled by tick duration. dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder
dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds()) dmg := int(dmgFloat)
if dmg < 1 { ad.DotRemainder = dmgFloat - float64(dmg)
dmg = 1 if dmg > 0 {
}
hero.HP -= dmg hero.HP -= dmg
totalDmg += dmg totalDmg += dmg
} }
} }
}
if hero.HP < 0 { if hero.HP < 0 {
hero.HP = 0 hero.HP = 0

@ -25,8 +25,19 @@ type CombatSimOptions struct {
// ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine. // 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. // 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 { 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 { if hero == nil || enemy == nil {
return false return false, 0
} }
tickRate := opts.TickRate tickRate := opts.TickRate
if tickRate <= 0 { if tickRate <= 0 {
@ -35,7 +46,7 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
now := start now := start
heroNext := now.Add(attackInterval(hero.EffectiveSpeed())) heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
enemyNext := now.Add(attackInterval(enemy.Speed)) enemyNext := now.Add(attackIntervalEnemy(enemy.Speed))
nextTick := now.Add(tickRate) nextTick := now.Add(tickRate)
lastTickAt := now lastTickAt := now
var regenRemainder float64 var regenRemainder float64
@ -63,7 +74,7 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
lastTickAt = now lastTickAt = now
if CheckDeath(hero, now) { if CheckDeath(hero, now) {
hero.HP = 0 hero.HP = 0
return false return false, now.Sub(start)
} }
} }
nextTick = nextTick.Add(tickRate) 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) { if !heroNext.After(enemyNext) && now.Equal(heroNext) {
ProcessAttack(hero, enemy, now) ProcessAttack(hero, enemy, now)
if !enemy.IsAlive() { if !enemy.IsAlive() {
return true return true, now.Sub(start)
} }
heroNext = now.Add(attackInterval(hero.EffectiveSpeed())) heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
continue continue
@ -83,16 +94,17 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
ProcessEnemyAttack(hero, enemy, now) ProcessEnemyAttack(hero, enemy, now)
if CheckDeath(hero, now) { if CheckDeath(hero, now) {
hero.HP = 0 hero.HP = 0
return false return false, now.Sub(start)
} }
if opts.AutoUsePotion != nil { if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now) _ = 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. // OfflineAutoPotionHook is a low-probability offline-only potion usage policy.

@ -182,7 +182,7 @@ func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Durat
hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier)) hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier))
} }
if ena.Before(now) { if ena.Before(now) {
ena = now.Add(attackInterval(cs.Enemy.Speed)) ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed))
} }
cs.HeroNextAttack = hna cs.HeroNextAttack = hna
cs.EnemyNextAttack = ena cs.EnemyNextAttack = ena
@ -943,7 +943,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
Hero: hero, Hero: hero,
Enemy: *enemy, Enemy: *enemy,
HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())), HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())),
EnemyNextAttack: now.Add(attackInterval(enemy.Speed)), EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)),
StartedAt: now, StartedAt: now,
LastTickAt: now, LastTickAt: now,
} }
@ -1426,7 +1426,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
} }
// Reschedule enemy's next attack. // 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{ heap.Push(&e.queue, &model.AttackEvent{
NextAttackAt: cs.EnemyNextAttack, NextAttackAt: cs.EnemyNextAttack,
IsHero: false, IsHero: false,
@ -1764,6 +1764,13 @@ func attackInterval(speed float64) time.Duration {
return interval 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. // enemyToInfo converts a model.Enemy to the WS payload info struct.
func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
return model.CombatEnemyInfo{ return model.CombatEnemyInfo{

@ -25,3 +25,13 @@ func TestAttackIntervalForNormalSpeed(t *testing.T) {
t.Fatalf("expected %s, got %s", want, got) 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)
}
}

@ -416,6 +416,34 @@ func PickEnemyForLevel(level int) model.Enemy {
return ScaleEnemyTemplate(picked, level) 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. // ScaleEnemyTemplate applies band-based level scaling to stats and rewards.
// Exported for reuse across handler and offline simulation. // Exported for reuse across handler and offline simulation.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy { func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {

@ -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 ───────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────
func parseHeroID(r *http.Request) (int64, error) { func parseHeroID(r *http.Request) (int64, error) {

@ -96,6 +96,8 @@ type ActiveDebuff struct {
Debuff Debuff `json:"debuff"` Debuff Debuff `json:"debuff"`
AppliedAt time.Time `json:"appliedAt"` AppliedAt time.Time `json:"appliedAt"`
ExpiresAt time.Time `json:"expiresAt"` 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. // IsExpired returns true if the debuff has expired relative to the given time.

@ -99,7 +99,7 @@ func seedDebuffMap() map[DebuffType]Debuff {
}, },
DebuffBurn: { DebuffBurn: {
Type: DebuffBurn, Name: "Burn", Type: DebuffBurn, Name: "Burn",
Duration: 40 * time.Second, Magnitude: 0.03, Duration: 40 * time.Second, Magnitude: 0.018,
}, },
DebuffStun: { DebuffStun: {
Type: DebuffStun, Name: "Stun", Type: DebuffStun, Name: "Stun",

@ -75,47 +75,47 @@ var EnemyTemplates = map[EnemyType]Enemy{
// --- Basic enemies --- // --- Basic enemies ---
EnemyWolf: { EnemyWolf: {
Type: EnemyWolf, Name: "Forest Wolf", 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, MinLevel: 1, MaxLevel: 5,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
}, },
EnemyBoar: { EnemyBoar: {
Type: EnemyBoar, Name: "Wild Boar", 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, MinLevel: 2, MaxLevel: 6,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
}, },
EnemyZombie: { EnemyZombie: {
Type: EnemyZombie, Name: "Rotting Zombie", 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, MinLevel: 3, MaxLevel: 8,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityPoison}, SpecialAbilities: []SpecialAbility{AbilityPoison},
}, },
EnemySpider: { EnemySpider: {
Type: EnemySpider, Name: "Cave Spider", 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, MinLevel: 4, MaxLevel: 9,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityCritical}, SpecialAbilities: []SpecialAbility{AbilityCritical},
}, },
EnemyOrc: { EnemyOrc: {
Type: EnemyOrc, Name: "Orc Warrior", 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, MinLevel: 5, MaxLevel: 12,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityBurst}, SpecialAbilities: []SpecialAbility{AbilityBurst},
}, },
EnemySkeletonArcher: { EnemySkeletonArcher: {
Type: EnemySkeletonArcher, Name: "Skeleton Archer", 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, MinLevel: 6, MaxLevel: 14,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityDodge}, SpecialAbilities: []SpecialAbility{AbilityDodge},
}, },
EnemyBattleLizard: { EnemyBattleLizard: {
Type: EnemyBattleLizard, Name: "Battle Lizard", 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, MinLevel: 7, MaxLevel: 15,
XPReward: 1, GoldReward: 1, XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityRegen}, SpecialAbilities: []SpecialAbility{AbilityRegen},
@ -124,42 +124,42 @@ var EnemyTemplates = map[EnemyType]Enemy{
// --- Elite enemies --- // --- Elite enemies ---
EnemyFireDemon: { EnemyFireDemon: {
Type: EnemyFireDemon, Name: "Fire Demon", 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, MinLevel: 10, MaxLevel: 20,
XPReward: 1, GoldReward: 1, IsElite: true, XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityBurn}, SpecialAbilities: []SpecialAbility{AbilityBurn},
}, },
EnemyIceGuardian: { EnemyIceGuardian: {
Type: EnemyIceGuardian, Name: "Ice Guardian", 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, MinLevel: 12, MaxLevel: 22,
XPReward: 1, GoldReward: 1, IsElite: true, XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityIceSlow}, SpecialAbilities: []SpecialAbility{AbilityIceSlow},
}, },
EnemySkeletonKing: { EnemySkeletonKing: {
Type: EnemySkeletonKing, Name: "Skeleton King", 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, MinLevel: 15, MaxLevel: 25,
XPReward: 1, GoldReward: 1, IsElite: true, XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityRegen, AbilitySummon}, SpecialAbilities: []SpecialAbility{AbilityRegen, AbilitySummon},
}, },
EnemyWaterElement: { EnemyWaterElement: {
Type: EnemyWaterElement, Name: "Water Element", 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, MinLevel: 18, MaxLevel: 28,
XPReward: 2, GoldReward: 1, IsElite: true, XPReward: 2, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilitySlow}, SpecialAbilities: []SpecialAbility{AbilitySlow},
}, },
EnemyForestWarden: { EnemyForestWarden: {
Type: EnemyForestWarden, Name: "Forest Warden", 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, MinLevel: 20, MaxLevel: 30,
XPReward: 2, GoldReward: 1, IsElite: true, XPReward: 2, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityRegen}, SpecialAbilities: []SpecialAbility{AbilityRegen},
}, },
EnemyLightningTitan: { EnemyLightningTitan: {
Type: EnemyLightningTitan, Name: "Lightning Titan", 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, MinLevel: 25, MaxLevel: 35,
XPReward: 3, GoldReward: 2, IsElite: true, XPReward: 3, GoldReward: 2, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityStun, AbilityChainLightning}, SpecialAbilities: []SpecialAbility{AbilityStun, AbilityChainLightning},

@ -13,16 +13,6 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible) 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). // 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. // When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct { type ExcursionSession struct {

@ -126,7 +126,11 @@ func (h *Hero) LevelUp() bool {
// v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3). // v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3).
cfg := tuning.Get() cfg := tuning.Get()
if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 { 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 { if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack++ h.Attack++

@ -285,19 +285,19 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
t.Run("L30", func(t *testing.T) { t.Run("L30", func(t *testing.T) {
h := snap(30) 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) 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.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })
t.Run("L45", func(t *testing.T) { t.Run("L45", func(t *testing.T) {
h := snap(45) 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) 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)) t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })

@ -129,6 +129,9 @@ func New(deps Deps) *chi.Mux {
r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig) r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig)
r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig) r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig)
r.Post("/buff-debuff-config/reload", adminH.ReloadBuffDebuffConfig) 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", adminH.ListPayments)
r.Get("/payments/{paymentId}", adminH.GetPayment) r.Get("/payments/{paymentId}", adminH.GetPayment)
r.Post("/payments/set-webhook", paymentsH.SetWebhook) r.Post("/payments/set-webhook", paymentsH.SetWebhook)

@ -55,6 +55,88 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT
return out, nil 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 { func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
v := strings.TrimSpace(strings.ToLower(raw)) v := strings.TrimSpace(strings.ToLower(raw))
v = strings.TrimPrefix(v, "gear.slot.") v = strings.TrimPrefix(v, "gear.slot.")

@ -1,17 +1,22 @@
package tuning package tuning
// Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax). // 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 ( const (
DefaultEnemyCombatDamageScale = 1.0 DefaultEnemyAttackIntervalMultiplier = 1.5 // enemyAttackIntervalMultiplier
DefaultEnemyCombatDamageRollMin = 0.8 DefaultEnemyCombatDamageScale = 1.0 // enemyCombatDamageScale
DefaultEnemyCombatDamageRollMin = 0.82
DefaultEnemyCombatDamageRollMax = 1.0 DefaultEnemyCombatDamageRollMax = 1.0
) )
// Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below). // 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. // Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required.
const ( const (
DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault DefaultEnemyRegenDefault = 0.006 // enemyRegenDefault
DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing DefaultEnemyRegenSkeletonKing = 0.0015 // enemyRegenSkeletonKing
DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden DefaultEnemyRegenForestWarden = 0.003 // enemyRegenForestWarden
DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard DefaultEnemyRegenBattleLizard = 0.004 // enemyRegenBattleLizard
) )

@ -95,6 +95,8 @@ type Values struct {
EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"`
EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"`
EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` 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"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
@ -137,6 +139,8 @@ type Values struct {
XPCurveLateScale float64 `json:"xpCurveLateScale"` XPCurveLateScale float64 `json:"xpCurveLateScale"`
LevelUpHPEvery int64 `json:"levelUpHpEvery"` 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"` LevelUpATKEvery int64 `json:"levelUpAtkEvery"`
LevelUpDEFEvery int64 `json:"levelUpDefEvery"` LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
LevelUpSTREvery int64 `json:"levelUpStrEvery"` LevelUpSTREvery int64 `json:"levelUpStrEvery"`
@ -286,13 +290,15 @@ func DefaultValues() Values {
NPCCostNearbyRadius: 3.0, NPCCostNearbyRadius: 3.0,
QuestOffersPerNPC: 2, QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 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, CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10, CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyDodgeChance: 0.20, EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier,
EnemyDodgeChance: 0.14,
EnemyCriticalMinChance: 0.10, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20, EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12, HeroCritChanceCap: 0.12,
@ -316,7 +322,7 @@ func DefaultValues() Values {
SummonDamageDivisor: 4, SummonDamageDivisor: 4,
LuckBuffMultiplier: 1.75, LuckBuffMultiplier: 1.75,
MinAttackIntervalMs: 250, MinAttackIntervalMs: 250,
CombatPaceMultiplier: 5, CombatPaceMultiplier: 14,
PotionHealPercent: 0.30, PotionHealPercent: 0.30,
PotionAutoUseThreshold: 0.30, PotionAutoUseThreshold: 0.30,
ReviveHpPercent: 0.50, ReviveHpPercent: 0.50,
@ -327,12 +333,13 @@ func DefaultValues() Values {
XPCurveMidScale: 1.15, XPCurveMidScale: 1.15,
XPCurveLateBase: 23000, XPCurveLateBase: 23000,
XPCurveLateScale: 1.10, XPCurveLateScale: 1.10,
LevelUpHPEvery: 10, LevelUpHPEvery: 4,
LevelUpATKEvery: 30, LevelUpHpBase: 10,
LevelUpDEFEvery: 30, LevelUpATKEvery: 4,
LevelUpSTREvery: 40, LevelUpDEFEvery: 5,
LevelUpCONEvery: 50, LevelUpSTREvery: 12,
LevelUpAGIEvery: 60, LevelUpCONEvery: 14,
LevelUpAGIEvery: 20,
LevelUpLUCKEvery: 100, LevelUpLUCKEvery: 100,
AgilityCoef: 0.03, AgilityCoef: 0.03,
MaxAttackSpeed: 4.0, MaxAttackSpeed: 4.0,
@ -353,12 +360,12 @@ func DefaultValues() Values {
ResurrectionRefillPriceRUB: 150, ResurrectionRefillPriceRUB: 150,
MaxRevivesFree: 1, MaxRevivesFree: 1,
MaxRevivesSubscriber: 2, MaxRevivesSubscriber: 2,
EnemyScaleBandHP: 0.05, EnemyScaleBandHP: 0.062,
EnemyScaleOvercapHP: 0.025, EnemyScaleOvercapHP: 0.031,
EnemyScaleBandATK: 0.035, EnemyScaleBandATK: 0.044,
EnemyScaleOvercapATK: 0.018, EnemyScaleOvercapATK: 0.024,
EnemyScaleBandDEF: 0.035, EnemyScaleBandDEF: 0.038,
EnemyScaleOvercapDEF: 0.018, EnemyScaleOvercapDEF: 0.020,
EnemyScaleBandXP: 0.05, EnemyScaleBandXP: 0.05,
EnemyScaleOvercapXP: 0.03, EnemyScaleOvercapXP: 0.03,
EnemyScaleBandGold: 0.05, EnemyScaleBandGold: 0.05,
@ -461,6 +468,15 @@ func EffectiveEnemyRegenBattleLizard() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard) 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) { func Set(v Values) {
current.Store(&v) current.Store(&v)
} }

Loading…
Cancel
Save