rebalance start

master
Denis Ranneft 1 month ago
parent 86ffebf26a
commit 11d2c41e90

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
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 =>
`<label style="display:inline-block;margin:4px 10px 4px 0"><input type="checkbox" id="cem-ab-${a}" ${abs.has(a) ? "checked" : ""} /> ${e(a)}</label>`
).join("");
return `
<div class="card">
<h4>Edit enemy: <kbd>${e(ed.type)}</kbd></h4>
<p class="muted">Запись в таблице <kbd>enemies</kbd>. После сохранения сервер подставляет <kbd>hp</kbd> = <kbd>maxHp</kbd> и перезагружает шаблоны в памяти.</p>
<input type="hidden" id="cem-type" value="${e(ed.type)}" />
<div class="row-2">
<div><label>type (id строки)</label><input id="cem-type-ro" value="${e(ed.type)}" disabled /></div>
<div><label>name</label><input id="cem-name" value="${e(ed.name)}" /></div>
</div>
<div class="row-2">
<div><label>maxHp</label><input id="cem-maxHp" type="number" value="${e(ed.maxHp)}" title="Базовые HP шаблона" /></div>
<div><label>isElite</label><label style="display:block;margin-top:8px"><input type="checkbox" id="cem-isElite" ${ed.isElite ? "checked" : ""} /> elite</label></div>
</div>
<div class="row-2">
<div><label>attack</label><input id="cem-attack" type="number" value="${e(ed.attack)}" /></div>
<div><label>defense</label><input id="cem-defense" type="number" value="${e(ed.defense)}" /></div>
</div>
<div class="row-2">
<div><label>speed (атак/сек)</label><input id="cem-speed" type="number" step="any" value="${e(ed.speed)}" /></div>
<div><label>critChance (01)</label><input id="cem-critChance" type="number" step="any" value="${e(ed.critChance)}" /></div>
</div>
<div class="row-2">
<div><label>minLevel</label><input id="cem-minLevel" type="number" value="${e(ed.minLevel)}" /></div>
<div><label>maxLevel</label><input id="cem-maxLevel" type="number" value="${e(ed.maxLevel)}" /></div>
</div>
<div class="row-2">
<div><label>xpReward</label><input id="cem-xpReward" type="number" value="${e(ed.xpReward)}" /></div>
<div><label>goldReward</label><input id="cem-goldReward" type="number" value="${e(ed.goldReward)}" /></div>
</div>
<div><span class="muted">Special abilities</span></div>
<div style="margin:6px 0 10px">${abChecks}</div>
<button class="btn" onclick="withAction(saveContentEnemy)">Save</button>
<button class="btn" onclick="closeContentEnemyEditor()">Cancel</button>
</div>`;
}
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 @@
</div>`;
}
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 `<tr>
<td><kbd title="id в БД">${e(en.id)}</kbd></td>
<td><kbd>${e(en.type)}</kbd></td>
<td>${e(en.name)}</td>
<td>${e(elite)}</td>
<td>${e(en.minLevel)}${e(en.maxLevel)}</td>
<td>${e(en.maxHp)}</td>
<td>${e(en.attack)}/${e(en.defense)}</td>
<td>${e(en.speed)}</td>
<td>${e(en.critChance)}</td>
<td>${e(en.xpReward)}/${e(en.goldReward)}</td>
<td class="muted" style="max-width:180px;overflow:hidden;text-overflow:ellipsis" title="${e(abs)}">${e(abs || "—")}</td>
<td><button class="btn" onclick="openContentEnemyEditorByType(${JSON.stringify(en.type)})">Edit</button></td>
</tr>`;
}).join("");
return `
${contentEnemyEditorHtml()}
<div class="card">
<h3>Монстры (шаблоны врагов)</h3>
<p class="muted">Таблица <kbd>enemies</kbd>: базовые статы, уровни, награды, способности. Изменения после «Save» сразу попадают в память процесса. Новые типы добавляются миграциями/сидом, не из этой формы.</p>
<button class="btn" onclick="withAction(loadContentEnemies)">Обновить из БД</button>
<button class="btn" onclick="withAction(reloadEnemyTemplatesOnly)">Только reload в память</button>
</div>
<div class="card">
<table class="table">
<thead><tr>
<th>ID</th><th>Type</th><th>Name</th><th>Class</th><th>Levels</th><th>maxHP</th><th>Atk/Def</th><th>Spd</th><th>Crit</th><th>XP/Au</th><th>Abilities</th><th></th>
</tr></thead>
<tbody>${rows || `<tr><td colspan="12" class="muted">Нет данных — нажмите «Обновить из БД»</td></tr>`}</tbody>
</table>
${pagerHtml("contentEnemies", page.page, page.total)}
</div>`;
}
function sectionTowns() {
const townsPage = paged(state.questTowns, "towns", 8);
const towns = townsPage.items.map(t => `<div class="list-row" onclick="withAction(() => selectTown(${t.id}))"><strong>${e(t.name)}</strong><span>Lvl ${e(t.levelMin)}-${e(t.levelMax)}</span><span></span><span>ID ${e(t.id)}</span></div>`).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();

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

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

@ -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.300.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.510 min band)", r.MedianDur)
}
}

@ -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"
)

@ -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;

@ -0,0 +1,6 @@
-- Stretch combat pacing toward ~5 min median (was ~1.52 min at pace 9). attackInterval scales linearly with combatPaceMultiplier.
UPDATE runtime_config
SET
payload = payload || '{"combatPaceMultiplier": 28}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -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';

@ -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;

@ -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;

@ -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;

@ -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 715.
-- 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';
Loading…
Cancel
Save