diff --git a/admin-web/index.html b/admin-web/index.html
index fa12e4c..440f20b 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -86,6 +86,8 @@
contentGearRows: [],
contentGearEditor: null,
contentQuestEditor: null,
+ contentEnemies: [],
+ contentEnemyEditor: null,
gearFilterSlot: "",
gearFilterRarity: "",
gearFilterSubtype: "",
@@ -115,6 +117,11 @@
/** Matches model.AllBuffTypes / AllDebuffTypes (admin manual apply). */
const ADMIN_BUFF_TYPES = ["rush", "rage", "shield", "luck", "resurrection", "heal", "power_potion", "war_cry"];
const ADMIN_DEBUFF_TYPES = ["poison", "freeze", "burn", "stun", "slow", "weaken", "ice_slow"];
+ /** model.SpecialAbility — чекбоксы в редакторе врагов */
+ const ADMIN_ENEMY_SPECIAL_ABILITIES = [
+ "burn", "slow", "critical", "poison", "freeze", "ice_slow", "stun", "dodge",
+ "regen", "burst", "chain_lightning", "summon"
+ ];
function e(v) { return String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); }
function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; }
@@ -962,6 +969,92 @@
}
async function loadContentGearBase() { const data = await api("content/gear-base"); state.contentGearRows = data.gear || []; render(); }
async function loadContentQuests() { const data = await api("content/quests"); state.contentQuests = data.quests || []; render(); }
+ async function loadContentEnemies() {
+ const data = await api("content/enemies");
+ state.contentEnemies = data.enemies || [];
+ render();
+ }
+ function openContentEnemyEditorByType(enemyType) {
+ const row = (state.contentEnemies || []).find(x => x.type === enemyType);
+ if (!row) return;
+ state.contentEnemyEditor = Object.assign({}, row, {
+ _abilities: new Set(row.specialAbilities || [])
+ });
+ render();
+ }
+ function closeContentEnemyEditor() { state.contentEnemyEditor = null; render(); }
+ async function saveContentEnemy() {
+ const ed = state.contentEnemyEditor;
+ if (!ed) return;
+ const type = String(ed.type || "").trim();
+ const maxHp = Number(document.getElementById("cem-maxHp").value || 0);
+ const body = {
+ type,
+ name: document.getElementById("cem-name").value.trim(),
+ maxHp,
+ hp: maxHp,
+ attack: Number(document.getElementById("cem-attack").value || 0),
+ defense: Number(document.getElementById("cem-defense").value || 0),
+ speed: Number(document.getElementById("cem-speed").value || 0),
+ critChance: Number(document.getElementById("cem-critChance").value || 0),
+ minLevel: Number(document.getElementById("cem-minLevel").value || 1),
+ maxLevel: Number(document.getElementById("cem-maxLevel").value || 1),
+ xpReward: Number(document.getElementById("cem-xpReward").value || 0),
+ goldReward: Number(document.getElementById("cem-goldReward").value || 0),
+ isElite: !!document.getElementById("cem-isElite").checked,
+ specialAbilities: ADMIN_ENEMY_SPECIAL_ABILITIES.filter(a => document.getElementById("cem-ab-" + a).checked)
+ };
+ await api("content/enemies/" + encodeURIComponent(type), { method: "PUT", body: JSON.stringify(body) });
+ state.contentEnemyEditor = null;
+ await loadContentEnemies();
+ setMessage("Enemy template saved; in-memory templates reloaded");
+ }
+ async function reloadEnemyTemplatesOnly() {
+ await api("content/enemies/reload", { method: "POST", body: "{}" });
+ setMessage("Enemy templates reloaded from DB");
+ }
+ function contentEnemyEditorHtml() {
+ const ed = state.contentEnemyEditor;
+ if (!ed) return "";
+ const abs = ed._abilities instanceof Set ? ed._abilities : new Set(ed.specialAbilities || []);
+ const abChecks = ADMIN_ENEMY_SPECIAL_ABILITIES.map(a =>
+ ` ${e(a)} `
+ ).join("");
+ return `
+
+
Edit enemy: ${e(ed.type)}
+
Запись в таблице enemies . После сохранения сервер подставляет hp = maxHp и перезагружает шаблоны в памяти.
+
+
+
+
+
+
+
+
Special abilities
+
${abChecks}
+
Save
+
Cancel
+
`;
+ }
function openNewContentGearEditor() {
state.contentGearEditor = {
id: 0, slot: "main_hand", formId: "", name: "", subtype: "", rarity: "common", ilvl: 1,
@@ -1228,6 +1321,7 @@
render();
if (tab === "constants" && !state.runtime) withAction(loadRuntime);
if (tab === "buffDebuff" && !state.buffDebuff) withAction(loadBuffDebuff);
+ if (tab === "enemies") withAction(loadContentEnemies);
}
function sectionServer() {
@@ -1646,6 +1740,45 @@
`;
}
+ function sectionEnemies() {
+ const page = paged(state.contentEnemies, "contentEnemies", 15);
+ const rows = page.items.map(en => {
+ const abs = (en.specialAbilities || []).join(", ");
+ const elite = en.isElite ? "elite" : "base";
+ return `
+ ${e(en.id)}
+ ${e(en.type)}
+ ${e(en.name)}
+ ${e(elite)}
+ ${e(en.minLevel)}–${e(en.maxLevel)}
+ ${e(en.maxHp)}
+ ${e(en.attack)}/${e(en.defense)}
+ ${e(en.speed)}
+ ${e(en.critChance)}
+ ${e(en.xpReward)}/${e(en.goldReward)}
+ ${e(abs || "—")}
+ Edit
+ `;
+ }).join("");
+ return `
+ ${contentEnemyEditorHtml()}
+
+
Монстры (шаблоны врагов)
+
Таблица enemies : базовые статы, уровни, награды, способности. Изменения после «Save» сразу попадают в память процесса. Новые типы добавляются миграциями/сидом, не из этой формы.
+
Обновить из БД
+
Только reload в память
+
+
+
+
+ ID Type Name Class Levels maxHP Atk/Def Spd Crit XP/Au Abilities
+
+ ${rows || `Нет данных — нажмите «Обновить из БД» `}
+
+ ${pagerHtml("contentEnemies", page.page, page.total)}
+
`;
+ }
+
function sectionTowns() {
const townsPage = paged(state.questTowns, "towns", 8);
const towns = townsPage.items.map(t => `${e(t.name)} Lvl ${e(t.levelMin)}-${e(t.levelMax)} ID ${e(t.id)}
`).join("");
@@ -1710,6 +1843,7 @@
if (state.tab === "constants") return sectionConstants();
if (state.tab === "buffDebuff") return sectionBuffDebuff();
if (state.tab === "gear") return sectionGear();
+ if (state.tab === "enemies") return sectionEnemies();
if (state.tab === "quests") return sectionQuests();
if (state.tab === "towns") return sectionTowns();
if (state.tab === "payments") return sectionPayments();
diff --git a/backend/internal/game/balance_monte_carlo.go b/backend/internal/game/balance_monte_carlo.go
new file mode 100644
index 0000000..16aa4aa
--- /dev/null
+++ b/backend/internal/game/balance_monte_carlo.go
@@ -0,0 +1,94 @@
+package game
+
+import (
+ "math/rand"
+ "sort"
+ "time"
+
+ "github.com/denisovdennis/autohero/internal/model"
+)
+
+// BalanceEnemyMode selects how the opponent is chosen for balance Monte Carlo.
+type BalanceEnemyMode int
+
+const (
+ // BalanceEnemyWolfOnly uses a single scaled Forest Wolf (canonical curve check).
+ BalanceEnemyWolfOnly BalanceEnemyMode = iota
+ // BalanceEnemyMixedSpawn matches PickEnemyForLevelWithRNG (weighted random template in band).
+ BalanceEnemyMixedSpawn
+)
+
+// BalanceMonteCarloResult aggregates outcomes from RunBalanceMonteCarlo.
+type BalanceMonteCarloResult struct {
+ Iterations int
+ Wins int
+ WinRate float64
+ MedianDur time.Duration
+ P90Dur time.Duration
+ MeanDur time.Duration
+}
+
+var balanceSimStart = time.Unix(1_700_000_000, 0)
+
+// RunBalanceMonteCarlo runs N independent fights at hero level against scaled enemies.
+// Per-iteration RNG is derived from seed so results are reproducible.
+// Global math/rand is re-seeded per fight for damage/crit/dodge rolls (same as legacy combat).
+func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile ReferenceGearProfile, enemyMode BalanceEnemyMode) BalanceMonteCarloResult {
+ if iterations <= 0 {
+ return BalanceMonteCarloResult{}
+ }
+ var wins int
+ durations := make([]time.Duration, 0, iterations)
+ var sumDur time.Duration
+
+ for i := 0; i < iterations; i++ {
+ var gearRng *rand.Rand
+ if gearProfile == ReferenceGearRolled {
+ gearRng = rand.New(rand.NewSource(seed + int64(i)*1_000_003))
+ }
+ baseHero := NewReferenceHeroForBalance(level, gearProfile, gearRng)
+ hero := CloneHeroForCombatSim(baseHero)
+
+ // Combat RNG (damage rolls, dodge, crit, debuff procs).
+ rand.Seed(seed + int64(i)*9_999_983)
+
+ var enemy model.Enemy
+ switch enemyMode {
+ case BalanceEnemyWolfOnly:
+ tmpl := model.EnemyTemplates[model.EnemyWolf]
+ enemy = ScaleEnemyTemplate(tmpl, level)
+ case BalanceEnemyMixedSpawn:
+ pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001))
+ enemy = PickEnemyForLevelWithRNG(level, pickRNG)
+ default:
+ tmpl := model.EnemyTemplates[model.EnemyWolf]
+ enemy = ScaleEnemyTemplate(tmpl, level)
+ }
+
+ survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, balanceSimStart, CombatSimOptions{
+ TickRate: 100 * time.Millisecond,
+ })
+ if survived {
+ wins++
+ }
+ durations = append(durations, elapsed)
+ sumDur += elapsed
+ }
+
+ sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
+ median := durations[len(durations)/2]
+ p90idx := int(0.9 * float64(len(durations)-1))
+ if p90idx < 0 {
+ p90idx = 0
+ }
+ p90 := durations[p90idx]
+
+ return BalanceMonteCarloResult{
+ Iterations: iterations,
+ Wins: wins,
+ WinRate: float64(wins) / float64(iterations),
+ MedianDur: median,
+ P90Dur: p90,
+ MeanDur: time.Duration(int64(sumDur) / int64(iterations)),
+ }
+}
diff --git a/backend/internal/game/balance_reference.go b/backend/internal/game/balance_reference.go
new file mode 100644
index 0000000..6d70247
--- /dev/null
+++ b/backend/internal/game/balance_reference.go
@@ -0,0 +1,136 @@
+package game
+
+import (
+ "math/rand"
+ "time"
+
+ "github.com/denisovdennis/autohero/internal/model"
+ "github.com/denisovdennis/autohero/internal/tuning"
+)
+
+// ReferenceGearProfile selects how ilvl/rarity are chosen for balance simulations.
+type ReferenceGearProfile int
+
+const (
+ // ReferenceGearMedian uses ilvl == hero level and common Iron Sword + Chainmail (deterministic, low noise).
+ ReferenceGearMedian ReferenceGearProfile = iota
+ // ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread).
+ ReferenceGearRolled
+)
+
+// NewReferenceHeroForBalance builds a hero at the given level with sword + medium chest
+// scaled per spec §6.4 (IlvlFactor, RarityMultiplier). Stats follow the same level-up path
+// as gameplay (LevelUp cadences from tuning). HP is set to MaxHP for a fresh fight.
+// rng is used when profile is ReferenceGearRolled; may be nil for ReferenceGearMedian.
+func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *rand.Rand) *model.Hero {
+ if level < 1 {
+ level = 1
+ }
+ h := &model.Hero{
+ ID: 1,
+ Name: "BalanceRef",
+ HP: 100,
+ MaxHP: 100,
+ Attack: 10,
+ Defense: 5,
+ Speed: 1.0,
+ Strength: 1,
+ Constitution: 1,
+ Agility: 1,
+ Luck: 1,
+ State: model.StateWalking,
+ Level: 1,
+ Gear: make(map[model.EquipmentSlot]*model.GearItem),
+ }
+ for h.Level < level {
+ h.XP = model.XPToNextLevel(h.Level)
+ h.LevelUp()
+ }
+ h.HP = h.MaxHP
+
+ wIlvl, aIlvl := level, level
+ if profile == ReferenceGearRolled {
+ if rng == nil {
+ rng = rand.New(rand.NewSource(1))
+ }
+ wIlvl = rollIlvlForBalance(level, false, rng)
+ aIlvl = rollIlvlForBalance(level, false, rng)
+ }
+
+ // Typical mid-tier drops: uncommon sword + mail (catalog bases 10 / spec §6.4).
+ wPrimary := model.ScalePrimary(10, wIlvl, model.RarityUncommon)
+ h.Gear[model.SlotMainHand] = &model.GearItem{
+ Slot: model.SlotMainHand,
+ FormID: "gear.form.main_hand.sword",
+ Name: "Steel Sword",
+ Subtype: "sword",
+ Rarity: model.RarityUncommon,
+ Ilvl: wIlvl,
+ BasePrimary: 10,
+ PrimaryStat: wPrimary,
+ StatType: "attack",
+ SpeedModifier: 1.0,
+ CritChance: 0.05,
+ }
+ aPrimary := model.ScalePrimary(10, aIlvl, model.RarityUncommon)
+ h.Gear[model.SlotChest] = &model.GearItem{
+ Slot: model.SlotChest,
+ FormID: "gear.form.chest.medium",
+ Name: "Reinforced Mail",
+ Subtype: "medium",
+ Rarity: model.RarityUncommon,
+ Ilvl: aIlvl,
+ BasePrimary: 10,
+ PrimaryStat: aPrimary,
+ StatType: "defense",
+ SpeedModifier: 1.0,
+ }
+
+ now := time.Now()
+ h.RefreshDerivedCombatStats(now)
+ return h
+}
+
+// CloneHeroForCombatSim returns a deep enough copy for ResolveCombatToEnd (gear items copied).
+func CloneHeroForCombatSim(h *model.Hero) *model.Hero {
+ if h == nil {
+ return nil
+ }
+ cp := *h
+ if h.Gear != nil {
+ cp.Gear = make(map[model.EquipmentSlot]*model.GearItem, len(h.Gear))
+ for k, v := range h.Gear {
+ if v != nil {
+ gv := *v
+ cp.Gear[k] = &gv
+ } else {
+ cp.Gear[k] = nil
+ }
+ }
+ }
+ return &cp
+}
+
+// rollIlvlForBalance mirrors model.RollIlvl but uses rng for deterministic simulations.
+func rollIlvlForBalance(monsterLevel int, isElite bool, rng *rand.Rand) int {
+ var delta int
+ if isElite {
+ r := rng.Float64()
+ cfg := tuning.Get()
+ switch {
+ case r < cfg.RollIlvlEliteBaseChance:
+ delta = 0
+ case r < cfg.RollIlvlEliteBaseChance+cfg.RollIlvlElitePlusOneChance:
+ delta = 1
+ default:
+ delta = 2
+ }
+ } else {
+ delta = rng.Intn(3) - 1
+ }
+ ilvl := monsterLevel + delta
+ if ilvl < 1 {
+ ilvl = 1
+ }
+ return ilvl
+}
diff --git a/backend/internal/game/combat_balance_test.go b/backend/internal/game/combat_balance_test.go
new file mode 100644
index 0000000..7289b8a
--- /dev/null
+++ b/backend/internal/game/combat_balance_test.go
@@ -0,0 +1,71 @@
+package game
+
+import (
+ "testing"
+ "time"
+)
+
+func TestBalanceMonteCarlo_WolfMedianGear(t *testing.T) {
+ n := 8000
+ if testing.Short() {
+ n = 1500
+ }
+ const seed = 424242
+ level := 5
+ r := RunBalanceMonteCarlo(level, n, seed, ReferenceGearMedian, BalanceEnemyWolfOnly)
+ t.Logf("level=%d wolf-only median-gear: win=%.3f med=%s p90=%s mean=%s (n=%d)",
+ level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond), r.MeanDur.Round(time.Millisecond), n)
+ if r.Iterations != n {
+ t.Fatalf("iterations: got %d want %d", r.Iterations, n)
+ }
+}
+
+func TestBalanceMonteCarlo_MixedSpawnL10(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+ const n = 4000
+ const seed = 777
+ r := RunBalanceMonteCarlo(10, n, seed, ReferenceGearRolled, BalanceEnemyMixedSpawn)
+ t.Logf("level=10 mixed rolled-gear: win=%.3f med=%s p90=%s", r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
+}
+
+func TestBalanceMonteCarlo_WolfCurve(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+ const n = 6000
+ const seed = 99
+ for _, level := range []int{5, 10, 15, 20, 25} {
+ r := RunBalanceMonteCarlo(level, n, seed+int64(level*17), ReferenceGearMedian, BalanceEnemyWolfOnly)
+ t.Logf("L%2d wolf-only median-gear: win=%.3f med=%s p90=%s", level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
+ }
+}
+
+func TestBalanceMonteCarlo_CurveProbe(t *testing.T) {
+ if testing.Short() {
+ t.Skip("curve probe")
+ }
+ const n = 5000
+ const seed = 2026
+ for _, level := range []int{1, 3, 5, 10, 15, 20} {
+ r := RunBalanceMonteCarlo(level, n, seed+int64(level), ReferenceGearMedian, BalanceEnemyMixedSpawn)
+ t.Logf("L%2d mixed median-gear: win=%.3f med=%s p90=%s", level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
+ }
+}
+
+// TestBalanceMonteCarlo_L5MixedRegression guards against extreme drift after tuning changes.
+func TestBalanceMonteCarlo_L5MixedRegression(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+ const n = 4000
+ r := RunBalanceMonteCarlo(5, n, 424242, ReferenceGearMedian, BalanceEnemyMixedSpawn)
+ if r.WinRate < 0.30 || r.WinRate > 0.95 {
+ t.Fatalf("L5 mixed win rate drift: %.3f (expected rough band 0.30–0.95)", r.WinRate)
+ }
+ // Mixed spawn has high variance; median duration should stay in a sane band after pace/damage retunes.
+ if r.MedianDur < 90*time.Second || r.MedianDur > 12*time.Minute {
+ t.Fatalf("L5 mixed median duration drift: %s (expected rough ~1.5–10 min band)", r.MedianDur)
+ }
+}
diff --git a/backend/internal/model/rest_kind.go b/backend/internal/model/rest_kind.go
new file mode 100644
index 0000000..6bb4994
--- /dev/null
+++ b/backend/internal/model/rest_kind.go
@@ -0,0 +1,11 @@
+package model
+
+// RestKind discriminates the context of a StateResting period.
+type RestKind string
+
+const (
+ RestKindNone RestKind = ""
+ RestKindTown RestKind = "town"
+ RestKindRoadside RestKind = "roadside"
+ RestKindAdventureInline RestKind = "adventure_inline"
+)
diff --git a/backend/migrations/000034_combat_balance_v2.sql b/backend/migrations/000034_combat_balance_v2.sql
new file mode 100644
index 0000000..2ee1ce8
--- /dev/null
+++ b/backend/migrations/000034_combat_balance_v2.sql
@@ -0,0 +1,43 @@
+-- Combat balance defaults (hero scaling, pace, enemy damage, level-up cadence) + burn DoT magnitude.
+-- Merges into existing JSON so other keys are preserved.
+
+UPDATE runtime_config
+SET
+ payload = payload || '{
+ "combatDamageScale": 0.432,
+ "combatDamageRollMin": 0.60,
+ "combatDamageRollMax": 1.10,
+ "enemyCombatDamageScale": 1.34,
+ "enemyCombatDamageRollMin": 0.82,
+ "enemyCombatDamageRollMax": 1.0,
+ "enemyDodgeChance": 0.14,
+ "combatPaceMultiplier": 9,
+ "minAttackIntervalMs": 250,
+ "levelUpHpEvery": 4,
+ "levelUpHpBase": 10,
+ "levelUpAtkEvery": 4,
+ "levelUpDefEvery": 5,
+ "levelUpStrEvery": 12,
+ "levelUpConEvery": 14,
+ "levelUpAgiEvery": 20,
+ "levelUpLuckEvery": 100,
+ "enemyScaleBandHp": 0.062,
+ "enemyScaleOvercapHp": 0.031,
+ "enemyScaleBandAtk": 0.044,
+ "enemyScaleOvercapAtk": 0.024,
+ "enemyScaleBandDef": 0.038,
+ "enemyScaleOvercapDef": 0.020
+ }'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
+
+UPDATE buff_debuff_config
+SET
+ payload = jsonb_set(
+ payload::jsonb,
+ '{debuffs,burn,magnitude}',
+ '0.018'::jsonb,
+ true
+ ),
+ updated_at = now()
+WHERE id = TRUE;
diff --git a/backend/migrations/000037_runtime_config_combat_pace_5min.sql b/backend/migrations/000037_runtime_config_combat_pace_5min.sql
new file mode 100644
index 0000000..1714dbc
--- /dev/null
+++ b/backend/migrations/000037_runtime_config_combat_pace_5min.sql
@@ -0,0 +1,6 @@
+-- Stretch combat pacing toward ~5 min median (was ~1.5–2 min at pace 9). attackInterval scales linearly with combatPaceMultiplier.
+UPDATE runtime_config
+SET
+ payload = payload || '{"combatPaceMultiplier": 28}'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
diff --git a/backend/migrations/000038_enemy_templates_sync.sql b/backend/migrations/000038_enemy_templates_sync.sql
new file mode 100644
index 0000000..27fd118
--- /dev/null
+++ b/backend/migrations/000038_enemy_templates_sync.sql
@@ -0,0 +1,93 @@
+-- Sync enemies table with server defaults (model/enemy.go): stats, narrower level bands, correct abilities.
+-- Apply on staging/production so DB matches code-used templates after LoadEnemyTemplates at startup.
+
+UPDATE enemies SET
+ name = 'Forest Wolf',
+ hp = 60, max_hp = 60, attack = 11, defense = 5, speed = 1.8, crit_chance = 0.05,
+ min_level = 1, max_level = 3, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{}', is_elite = false
+WHERE type = 'wolf';
+
+UPDATE enemies SET
+ name = 'Wild Boar',
+ hp = 74, max_hp = 74, attack = 19, defense = 8, speed = 0.8, crit_chance = 0.08,
+ min_level = 2, max_level = 4, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{}', is_elite = false
+WHERE type = 'boar';
+
+UPDATE enemies SET
+ name = 'Rotting Zombie',
+ hp = 108, max_hp = 108, attack = 17, defense = 8, speed = 0.5, crit_chance = 0.00,
+ min_level = 3, max_level = 6, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{poison}', is_elite = false
+WHERE type = 'zombie';
+
+UPDATE enemies SET
+ name = 'Cave Spider',
+ hp = 44, max_hp = 44, attack = 17, defense = 4, speed = 2.0, crit_chance = 0.15,
+ min_level = 4, max_level = 7, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{critical}', is_elite = false
+WHERE type = 'spider';
+
+UPDATE enemies SET
+ name = 'Orc Warrior',
+ hp = 118, max_hp = 118, attack = 22, defense = 13, speed = 1.0, crit_chance = 0.05,
+ min_level = 5, max_level = 9, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{burst}', is_elite = false
+WHERE type = 'orc';
+
+UPDATE enemies SET
+ name = 'Skeleton Archer',
+ hp = 96, max_hp = 96, attack = 24, defense = 11, speed = 1.3, crit_chance = 0.06,
+ min_level = 6, max_level = 11, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{dodge}', is_elite = false
+WHERE type = 'skeleton_archer';
+
+UPDATE enemies SET
+ name = 'Battle Lizard',
+ hp = 148, max_hp = 148, attack = 25, defense = 19, speed = 0.7, crit_chance = 0.03,
+ min_level = 7, max_level = 13, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{regen}', is_elite = false
+WHERE type = 'battle_lizard';
+
+UPDATE enemies SET
+ name = 'Fire Demon',
+ hp = 128, max_hp = 128, attack = 24, defense = 13, speed = 1.2, crit_chance = 0.10,
+ min_level = 10, max_level = 15, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{burn}', is_elite = true
+WHERE type = 'fire_demon';
+
+UPDATE enemies SET
+ name = 'Ice Guardian',
+ hp = 245, max_hp = 245, attack = 28, defense = 26, speed = 0.7, crit_chance = 0.04,
+ min_level = 12, max_level = 17, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{ice_slow}', is_elite = true
+WHERE type = 'ice_guardian';
+
+UPDATE enemies SET
+ name = 'Skeleton King',
+ hp = 365, max_hp = 365, attack = 42, defense = 28, speed = 0.9, crit_chance = 0.08,
+ min_level = 15, max_level = 21, xp_reward = 1, gold_reward = 1,
+ special_abilities = '{regen,summon}', is_elite = true
+WHERE type = 'skeleton_king';
+
+UPDATE enemies SET
+ name = 'Water Element',
+ hp = 455, max_hp = 455, attack = 37, defense = 22, speed = 0.8, crit_chance = 0.05,
+ min_level = 18, max_level = 24, xp_reward = 2, gold_reward = 1,
+ special_abilities = '{slow}', is_elite = true
+WHERE type = 'water_element';
+
+UPDATE enemies SET
+ name = 'Forest Warden',
+ hp = 610, max_hp = 610, attack = 34, defense = 37, speed = 0.5, crit_chance = 0.03,
+ min_level = 20, max_level = 26, xp_reward = 2, gold_reward = 1,
+ special_abilities = '{regen}', is_elite = true
+WHERE type = 'forest_warden';
+
+UPDATE enemies SET
+ name = 'Lightning Titan',
+ hp = 565, max_hp = 565, attack = 49, defense = 28, speed = 1.5, crit_chance = 0.12,
+ min_level = 25, max_level = 32, xp_reward = 3, gold_reward = 2,
+ special_abilities = '{stun,chain_lightning}', is_elite = true
+WHERE type = 'lightning_titan';
diff --git a/backend/migrations/000039_runtime_config_enemy_regen_rebalance.sql b/backend/migrations/000039_runtime_config_enemy_regen_rebalance.sql
new file mode 100644
index 0000000..654bebe
--- /dev/null
+++ b/backend/migrations/000039_runtime_config_enemy_regen_rebalance.sql
@@ -0,0 +1,12 @@
+-- Rebalance enemy regen: old values (e.g. 4%/s Skeleton King) healed a large fraction of MaxHP
+-- between slow hero attacks; net damage could go negative. Align DB payload with tuning defaults.
+UPDATE runtime_config
+SET
+ payload = payload || '{
+ "enemyRegenDefault": 0.006,
+ "enemyRegenSkeletonKing": 0.003,
+ "enemyRegenForestWarden": 0.003,
+ "enemyRegenBattleLizard": 0.004
+ }'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
diff --git a/backend/migrations/000040_runtime_config_combat_snappier_weaker_hits.sql b/backend/migrations/000040_runtime_config_combat_snappier_weaker_hits.sql
new file mode 100644
index 0000000..c5c76d4
--- /dev/null
+++ b/backend/migrations/000040_runtime_config_combat_snappier_weaker_hits.sql
@@ -0,0 +1,11 @@
+-- Snappier combat: halve combatPaceMultiplier (more frequent attacks) and halve hero/enemy damage scales
+-- so DPS and median fight time stay in the same ballpark (DPS ~ damageScale/pace).
+UPDATE runtime_config
+SET
+ payload = payload || '{
+ "combatPaceMultiplier": 14,
+ "combatDamageScale": 0.216,
+ "enemyCombatDamageScale": 0.67
+ }'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
diff --git a/backend/migrations/000041_runtime_config_enemy_slower_heavier.sql b/backend/migrations/000041_runtime_config_enemy_slower_heavier.sql
new file mode 100644
index 0000000..60a3f88
--- /dev/null
+++ b/backend/migrations/000041_runtime_config_enemy_slower_heavier.sql
@@ -0,0 +1,9 @@
+-- Enemy swings: longer interval only for monsters, stronger per-hit damage (~same incoming DPS).
+UPDATE runtime_config
+SET
+ payload = payload || '{
+ "enemyAttackIntervalMultiplier": 1.5,
+ "enemyCombatDamageScale": 1.0
+ }'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
diff --git a/backend/migrations/000042_enemy_level_bands_catalog_skeleton_king_regen.sql b/backend/migrations/000042_enemy_level_bands_catalog_skeleton_king_regen.sql
new file mode 100644
index 0000000..d06617b
--- /dev/null
+++ b/backend/migrations/000042_enemy_level_bands_catalog_skeleton_king_regen.sql
@@ -0,0 +1,23 @@
+-- Align enemy min/max levels with docs/specification-content-catalog.md (inclusive bands).
+-- At L15, base mobs previously ended at 13 (battle_lizard), so encounters were elite-only; lizard now 7–15.
+-- Lower Skeleton King regen (runtime_config + matches tuning.DefaultEnemyRegenSkeletonKing).
+
+UPDATE runtime_config
+SET
+ payload = payload || '{"enemyRegenSkeletonKing": 0.0015}'::jsonb,
+ updated_at = now()
+WHERE id = TRUE;
+
+UPDATE enemies SET min_level = 1, max_level = 5 WHERE type = 'wolf';
+UPDATE enemies SET min_level = 2, max_level = 6 WHERE type = 'boar';
+UPDATE enemies SET min_level = 3, max_level = 8 WHERE type = 'zombie';
+UPDATE enemies SET min_level = 4, max_level = 9 WHERE type = 'spider';
+UPDATE enemies SET min_level = 5, max_level = 12 WHERE type = 'orc';
+UPDATE enemies SET min_level = 6, max_level = 14 WHERE type = 'skeleton_archer';
+UPDATE enemies SET min_level = 7, max_level = 15 WHERE type = 'battle_lizard';
+UPDATE enemies SET min_level = 10, max_level = 20 WHERE type = 'fire_demon';
+UPDATE enemies SET min_level = 12, max_level = 22 WHERE type = 'ice_guardian';
+UPDATE enemies SET min_level = 15, max_level = 25 WHERE type = 'skeleton_king';
+UPDATE enemies SET min_level = 18, max_level = 28 WHERE type = 'water_element';
+UPDATE enemies SET min_level = 20, max_level = 30 WHERE type = 'forest_warden';
+UPDATE enemies SET min_level = 25, max_level = 35 WHERE type = 'lightning_titan';