huge balance

master
Denis Ranneft 1 month ago
parent b6eb68bf11
commit 380fcd41ae

@ -0,0 +1,5 @@
{
"fire_demon": {
"specialAbilities": []
}
}

@ -181,7 +181,7 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari
if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin {
return 1.0
}
lo, hi := 0.04, 4.0
lo, hi := 0.04, 28.0
best := 1.0
for iter := 0; iter < 44; iter++ {
mid := (lo + hi) / 2
@ -210,12 +210,54 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari
if r.medOfMedDur > 0 && r.medOfMedDur >= lowSec && r.medOfMedDur <= highSec && r.medWinRate >= minMedWin {
return best
}
// Fallback: duration vs hpScale can be non-monotone for some ability mixes; coarse scan.
durTol := 2.5
for s := 1; s <= 48; s++ {
try := 0.06 + float64(s)*0.58
if try > 28.5 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase)
if r2.medWinRate < minMedWin*0.55 {
continue
}
if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec-durTol && r2.medOfMedDur <= highSec+durTol {
return try
}
}
// DoT / non-monotone duration: linear scan (binary search can miss a valid island).
for s := 1; s <= 55; s++ {
try := 0.12 + float64(s)*0.16
if try > 9.2 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase)
if r2.medWinRate < minMedWin*0.5 {
continue
}
if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec && r2.medOfMedDur <= highSec {
return try
}
}
return -1
}
func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 {
a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin)
if a >= 0 {
return a
}
hpLo2 := hpLow - 0.10
if hpLo2 < 0.15 {
hpLo2 = 0.15
}
return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHigh+0.02, minMedWin)
}
func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 {
r0 := aggregateGrid(tmpl, et, scenarios, hpScale, 1.0, n, seedBase)
if r0.medWinRate < minMedWin {
// Median win rate across cells can dip when HP is scaled for long fights; do not abort too early.
if r0.medWinRate < minMedWin*0.55 {
return -1
}
if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh {
@ -223,9 +265,9 @@ func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenario
}
var lo, hi float64
if r0.medOfMedHp < hpLow {
lo, hi = 0.08, 1.0
lo, hi = 0.02, 1.0
} else {
lo, hi = 1.0, 12.0
lo, hi = 1.0, 22.0
}
best := 1.0
for iter := 0; iter < 56; iter++ {
@ -252,6 +294,21 @@ func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenario
if r.medWinRate >= minMedWin && r.medOfMedHp >= hpLow && r.medOfMedHp <= hpHigh {
return best
}
// Fallback: medOfMedHp vs atk is not always monotone (DoT, stuns); scan a coarse grid.
hpTol := 0.03
for step := 0; step < 22; step++ {
try := 0.02 + float64(step)*0.28
if try > 8.5 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, hpScale, try, n, seedBase)
if r2.medWinRate < minMedWin {
continue
}
if r2.medOfMedHp >= hpLow-hpTol && r2.medOfMedHp <= hpHigh+hpTol {
return try
}
}
return -1
}
@ -291,42 +348,82 @@ func balanceArchetypeGrid(
}
seedBase := typeSeed
maxRounds := 6 + refinePasses*2
if maxRounds < 10 {
maxRounds = 10
}
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate)
if hpScale < 0 {
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinRate*0.80)
}
if hpScale < 0 {
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-40, highSec+40, minWinRate*0.72)
}
if hpScale < 0 {
return 0, 0, gridAggResult{}, false
}
atkScale = findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate)
atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate)
if atkScale < 0 {
return 0, 0, gridAggResult{}, false
}
for pass := 0; pass < refinePasses; pass++ {
for round := 0; round < maxRounds; round++ {
a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec {
durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec
hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh
if durOk && hpOk && a.medWinRate >= minWinRate {
break
}
if a.medOfMedDur > 0 {
if a.medOfMedDur > 0 && !durOk {
corr := targetSec / a.medOfMedDur
hpScale *= corr
if hpScale < 0.03 || hpScale > 6 {
if hpScale < 0.02 || hpScale > 28 {
break
}
if atk2 := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 {
atkScale = atk2
}
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 {
atkScale = atk2
} else {
break
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
if hp2 := findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinRate); hp2 > 0 {
hpScale = hp2
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 {
atkScale = atk2
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
for fix := 0; fix < 10; fix++ {
a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if a.medOfMedDur <= 0 {
break
}
if a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec {
break
}
if atk2 := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 {
hpScale *= targetSec / a.medOfMedDur
if hpScale < 0.02 || hpScale > 28 {
break
}
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinRate); atk2 > 0 {
atkScale = atk2
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
}
final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if final.medWinRate < minWinRate {
return 0, 0, gridAggResult{}, false
}
if atkScale < 0.22 {
return 0, 0, gridAggResult{}, false
}
if final.medOfMedDur > 0 && (final.medOfMedDur < lowSec || final.medOfMedDur > highSec) {
return 0, 0, gridAggResult{}, false
}
return hpScale, atkScale, final, true
}

@ -35,9 +35,9 @@ func main() {
gridMode = flag.Bool("grid", true, "equal-level × gear grid (median + rolled); false = legacy single midpoint + median gear only")
gearVariants = flag.Int("gear-variants", 4, "grid mode: median + (N-1) rolled ilvl profiles per level")
heroHpMid = flag.Float64("hero-hp-mid", 60, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center")
heroHpMidHigh = flag.Float64("hero-hp-mid-high", 20, "grid: high-tier center hero HP%% on wins when -tiered-targets")
heroHpPP = flag.Float64("hero-hp-pp", 7, "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)")
heroHpMid = flag.Float64("hero-hp-mid", 66, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center")
heroHpMidHigh = flag.Float64("hero-hp-mid-high", 26, "grid: high-tier center hero HP%% when -tiered-targets")
heroHpPP = flag.Float64("hero-hp-pp", 6, "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)")
refinePasses = flag.Int("refine", 2, "grid mode: duration/atk refinement passes")
iterations = flag.Int("iterations", 120, "runs per archetype (grid: per scenario cell)")
@ -50,7 +50,7 @@ func main() {
tierGamma = flag.Float64("tier-gamma", 1.5, "grid tiered: exponent for nonlinear interpolation (1=linear)")
tolerancePct = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]")
maxHeroHpPct = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)")
minWinRate = flag.Float64("min-win-rate", 0.35, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
minWinRate = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
printSQL = flag.Bool("sql", true, "print suggested UPDATE statements")
)
flag.Parse()
@ -125,17 +125,20 @@ func main() {
if *gearVariants < 2 {
log.Fatal("gear-variants must be at least 2")
}
var hpLowGrid, hpHighGrid float64
if *gridMode {
if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin {
log.Fatal("tier-level-max must be > tier-level-min")
}
var hpLowGridFlat, hpHighGridFlat float64
if *gridMode && !*tieredTargets {
hpMid := *heroHpMid / 100.0
pp := *heroHpPP / 100.0
hpLowGrid = hpMid - pp
hpHighGrid = hpMid + pp
if hpLowGrid < 0 {
hpLowGrid = 0
hpLowGridFlat = hpMid - pp
hpHighGridFlat = hpMid + pp
if hpLowGridFlat < 0 {
hpLowGridFlat = 0
}
if hpHighGrid > 1 {
hpHighGrid = 1
if hpHighGridFlat > 1 {
hpHighGridFlat = 1
}
}
maxHpFrac := *maxHeroHpPct / 100.0
@ -152,8 +155,8 @@ func main() {
tol := *tolerancePct / 100.0
lowTarget := time.Duration(float64(target) * (1.0 - tol))
highTarget := time.Duration(float64(target) * (1.0 + tol))
lowSec := *targetSec * (1.0 - tol)
highSec := *targetSec * (1.0 + tol)
legacyLowSec := *targetSec * (1.0 - tol)
legacyHighSec := *targetSec * (1.0 + tol)
overlayNote := ""
if strings.TrimSpace(*configJSON) != "" {
@ -161,8 +164,13 @@ func main() {
}
if *gridMode {
fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote)
if *tieredTargets {
fmt.Printf("# tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n",
*targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP)
} else {
fmt.Printf("# duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n",
lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, *heroHpMid, *heroHpPP)
legacyLowSec, legacyHighSec, 100*hpLowGridFlat, 100*hpHighGridFlat, *heroHpMid, *heroHpPP)
}
fmt.Printf("# levels: heroLv==enemyLv for each L in [min_level..max_level]; gear: %d variants\n\n", *gearVariants)
} else {
fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n# duration band=[%s,%s] min win rate %.0f%%\n\n",
@ -179,9 +187,57 @@ func main() {
fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
if *tieredTargets {
norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax)
tPow := math.Pow(norm, *tierGamma)
targetSecEff = *targetSec + (*targetSecHigh-*targetSec)*tPow
heroHpMidEff := *heroHpMid + (*heroHpMidHigh-*heroHpMid)*tPow
lowSec = targetSecEff * (1.0 - tol)
highSec = targetSecEff * (1.0 + tol)
hpMid := heroHpMidEff / 100.0
ppPts := *heroHpPP
if norm > 0.35 {
// Elites with DoT/stun: median hero HP on wins is harder to fit in a tight band.
ppPts += (norm - 0.35) * 45
}
pp := ppPts / 100.0
// Minimum remaining HP floor: 60% for early archetypes (norm<0.25), then linearly toward 20% at norm=1.
var floorHp float64
if norm < 0.25 {
floorHp = 0.60
} else {
u := (norm - 0.25) / 0.75
if u > 1 {
u = 1
}
// Steeper than linear 60%→20% so DoT-heavy mid-tier elites can still hit the HP band.
floorHp = math.Max(0.20, 0.60-0.65*u)
}
hpLowGrid = hpMid - pp
if hpLowGrid < floorHp {
hpLowGrid = floorHp
}
hpHighGrid = hpMid + pp
if hpLowGrid < 0 {
hpLowGrid = 0
}
if hpHighGrid > 1 {
hpHighGrid = 1
}
fmt.Printf("# tier: norm=%.3f curve=%.3f | targetSec=%.1fs heroHpMid=%.1f%% | bands dur [%.1f,%.1f] hp [%.1f%%,%.1f%%] (±%.1f pp)\n",
norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts)
} else {
targetSecEff = *targetSec
lowSec = legacyLowSec
highSec = legacyHighSec
hpLowGrid = hpLowGridFlat
hpHighGrid = hpHighGridFlat
}
hpScale, atkScale, gfinal, ok := balanceArchetypeGrid(
tmpl, et, scenarios, *iterations, typeSeed,
lowSec, highSec, *targetSec, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
)
if !ok {
fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et)
@ -456,6 +512,22 @@ func trimName32(s string) string {
return string(runes[:max-1]) + "…"
}
// archetypeTierNorm maps the midpoint of [MinLevel, MaxLevel] to [0,1] using global catalog bounds (e.g. 1..35).
func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
if globalMax <= globalMin {
return 0
}
mid := float64(t.MinLevel+t.MaxLevel) / 2
n := (mid - float64(globalMin)) / float64(globalMax-globalMin)
if n < 0 {
return 0
}
if n > 1 {
return 1
}
return n
}
// archetypeOrder returns which archetypes to balance: one row by -enemy-id, one by -enemy-type, or all (DB order).
func archetypeOrder(ctx context.Context, templates map[model.EnemyType]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]model.EnemyType, error) {
if enemyID > 0 {

@ -57,8 +57,9 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
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)
// Typical mid-tier drops: uncommon sword + mail (catalog bases; slightly above 10 for elite/DoT balance targets).
const refGearBase = 12
wPrimary := model.ScalePrimary(refGearBase, wIlvl, model.RarityUncommon)
h.Gear[model.SlotMainHand] = &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
@ -66,13 +67,13 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
Subtype: "sword",
Rarity: model.RarityUncommon,
Ilvl: wIlvl,
BasePrimary: 10,
BasePrimary: refGearBase,
PrimaryStat: wPrimary,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
aPrimary := model.ScalePrimary(10, aIlvl, model.RarityUncommon)
aPrimary := model.ScalePrimary(refGearBase, aIlvl, model.RarityUncommon)
h.Gear[model.SlotChest] = &model.GearItem{
Slot: model.SlotChest,
FormID: "gear.form.chest.medium",
@ -80,7 +81,7 @@ func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *ra
Subtype: "medium",
Rarity: model.RarityUncommon,
Ilvl: aIlvl,
BasePrimary: 10,
BasePrimary: refGearBase,
PrimaryStat: aPrimary,
StatType: "defense",
SpeedModifier: 1.0,

@ -0,0 +1,17 @@
-- Enemy combat stats from balanceall grid (see balanceall-out.txt and fire_demon run).
-- fire_demon: grid balance with in-memory overlay only (specialAbilities cleared for sim);
-- column special_abilities in DB is unchanged — burn remains unless changed elsewhere.
UPDATE enemies SET hp = 94, max_hp = 94, hp_per_level = 7.8681, attack = 20, attack_per_level = 2.7054 WHERE type = 'wolf';
UPDATE enemies SET hp = 102, max_hp = 102, hp_per_level = 8.2826, attack = 25, attack_per_level = 2.3190 WHERE type = 'boar';
UPDATE enemies SET hp = 107, max_hp = 107, hp_per_level = 6.9412, attack = 28, attack_per_level = 2.5898 WHERE type = 'zombie';
UPDATE enemies SET hp = 118, max_hp = 118, hp_per_level = 12.0614, attack = 24, attack_per_level = 2.7373 WHERE type = 'spider';
UPDATE enemies SET hp = 113, max_hp = 113, hp_per_level = 7.1338, attack = 27, attack_per_level = 2.6581 WHERE type = 'orc';
UPDATE enemies SET hp = 132, max_hp = 132, hp_per_level = 8.5586, attack = 28, attack_per_level = 2.2939 WHERE type = 'skeleton_archer';
UPDATE enemies SET hp = 105, max_hp = 105, hp_per_level = 5.7476, attack = 32, attack_per_level = 2.4140 WHERE type = 'battle_lizard';
UPDATE enemies SET hp = 193, max_hp = 193, hp_per_level = 12.7956, attack = 28, attack_per_level = 2.9413 WHERE type = 'fire_demon';
UPDATE enemies SET hp = 208, max_hp = 208, hp_per_level = 7.5649, attack = 37, attack_per_level = 3.0394 WHERE type = 'ice_guardian';
UPDATE enemies SET hp = 149, max_hp = 149, hp_per_level = 4.1663, attack = 24, attack_per_level = 1.8339 WHERE type = 'skeleton_king';
UPDATE enemies SET hp = 349, max_hp = 349, hp_per_level = 8.0285, attack = 45, attack_per_level = 3.1288 WHERE type = 'water_element';
UPDATE enemies SET hp = 338, max_hp = 338, hp_per_level = 6.1288, attack = 50, attack_per_level = 3.5033 WHERE type = 'forest_warden';
UPDATE enemies SET hp = 583, max_hp = 583, hp_per_level = 11.1055, attack = 48, attack_per_level = 2.9104 WHERE type = 'lightning_titan';

Binary file not shown.
Loading…
Cancel
Save