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 => + `` + ).join(""); + return ` +
+

Edit enemy: ${e(ed.type)}

+

Запись в таблице enemies. После сохранения сервер подставляет hp = maxHp и перезагружает шаблоны в памяти.

+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Special abilities
+
${abChecks}
+ + +
`; + } 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 || "—")} + + `; + }).join(""); + return ` + ${contentEnemyEditorHtml()} +
+

Монстры (шаблоны врагов)

+

Таблица enemies: базовые статы, уровни, награды, способности. Изменения после «Save» сразу попадают в память процесса. Новые типы добавляются миграциями/сидом, не из этой формы.

+ + +
+
+ + + + + ${rows || ``} +
IDTypeNameClassLevelsmaxHPAtk/DefSpdCritXP/AuAbilities
Нет данных — нажмите «Обновить из БД»
+ ${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';