@ -33,25 +33,25 @@ func main() {
configJSON = flag . String ( "config" , "" , "optional JSON file: partial enemy objects keyed by type string, merged over DB templates" )
configJSON = flag . String ( "config" , "" , "optional JSON file: partial enemy objects keyed by type string, merged over DB templates" )
gridMode = flag . Bool ( "grid" , true , "equal-level × gear grid (median + rolled); false = legacy single midpoint + median gear only" )
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" )
gearVariants = flag . Int ( "gear-variants" , 4 , "grid mode: median + (N-1) rolled ilvl profiles per level" )
heroHpMid = flag . Float64 ( "hero-hp-mid" , 6 0 , "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center" )
heroHpMid = flag . Float64 ( "hero-hp-mid" , 6 6 , "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center" )
heroHpMidHigh = flag . Float64 ( "hero-hp-mid-high" , 2 0 , "grid: high-tier center hero HP%% on wins when -tiered-targets")
heroHpMidHigh = flag . Float64 ( "hero-hp-mid-high" , 2 6 , "grid: high-tier center hero HP%% when -tiered-targets")
heroHpPP = flag . Float64 ( "hero-hp-pp" , 7 , "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)" )
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" )
refinePasses = flag . Int ( "refine" , 2 , "grid mode: duration/atk refinement passes" )
iterations = flag . Int ( "iterations" , 120 , "runs per archetype (grid: per scenario cell)" )
iterations = flag . Int ( "iterations" , 120 , "runs per archetype (grid: per scenario cell)" )
seed = flag . Int64 ( "seed" , 20260331 , "base rng seed" )
seed = flag . Int64 ( "seed" , 20260331 , "base rng seed" )
targetSec = flag . Float64 ( "target-sec" , 330 , "grid: low-tier center median duration (sec) when -tiered-targets; else flat center" )
targetSec = flag . Float64 ( "target-sec" , 330 , "grid: low-tier center median duration (sec) when -tiered-targets; else flat center" )
targetSecHigh = flag . Float64 ( "target-sec-high" , 660 , "grid: high-tier center median duration (sec) when -tiered-targets" )
targetSecHigh = flag . Float64 ( "target-sec-high" , 660 , "grid: high-tier center median duration (sec) when -tiered-targets" )
tieredTargets = flag . Bool ( "tiered-targets" , true , "grid: interpolate target duration and hero HP%% center from low tier to high tier by archetype level band" )
tieredTargets = flag . Bool ( "tiered-targets" , true , "grid: interpolate target duration and hero HP%% center from low tier to high tier by archetype level band" )
tierLevelMin = flag . Int ( "tier-level-min" , 1 , "grid tiered: global min level for normalization (catalog)" )
tierLevelMin = flag . Int ( "tier-level-min" , 1 , "grid tiered: global min level for normalization (catalog)" )
tierLevelMax = flag . Int ( "tier-level-max" , 35 , "grid tiered: global max level for normalization (catalog)" )
tierLevelMax = flag . Int ( "tier-level-max" , 35 , "grid tiered: global max level for normalization (catalog)" )
tierGamma = flag . Float64 ( "tier-gamma" , 1.5 , "grid tiered: exponent for nonlinear interpolation (1=linear)" )
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]" )
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)" )
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" )
printSQL = flag . Bool ( "sql" , true , "print suggested UPDATE statements" )
)
)
flag . Parse ( )
flag . Parse ( )
@ -125,17 +125,20 @@ func main() {
if * gearVariants < 2 {
if * gearVariants < 2 {
log . Fatal ( "gear-variants must be at least 2" )
log . Fatal ( "gear-variants must be at least 2" )
}
}
var hpLowGrid , hpHighGrid float64
if * gridMode && * tieredTargets && * tierLevelMax <= * tierLevelMin {
if * gridMode {
log . Fatal ( "tier-level-max must be > tier-level-min" )
}
var hpLowGridFlat , hpHighGridFlat float64
if * gridMode && ! * tieredTargets {
hpMid := * heroHpMid / 100.0
hpMid := * heroHpMid / 100.0
pp := * heroHpPP / 100.0
pp := * heroHpPP / 100.0
hpLowGrid = hpMid - pp
hpLowGrid Flat = hpMid - pp
hpHighGrid = hpMid + pp
hpHighGrid Flat = hpMid + pp
if hpLowGrid < 0 {
if hpLowGrid Flat < 0 {
hpLowGrid = 0
hpLowGrid Flat = 0
}
}
if hpHighGrid > 1 {
if hpHighGrid Flat > 1 {
hpHighGrid = 1
hpHighGrid Flat = 1
}
}
}
}
maxHpFrac := * maxHeroHpPct / 100.0
maxHpFrac := * maxHeroHpPct / 100.0
@ -152,8 +155,8 @@ func main() {
tol := * tolerancePct / 100.0
tol := * tolerancePct / 100.0
lowTarget := time . Duration ( float64 ( target ) * ( 1.0 - tol ) )
lowTarget := time . Duration ( float64 ( target ) * ( 1.0 - tol ) )
highTarget := time . Duration ( float64 ( target ) * ( 1.0 + tol ) )
highTarget := time . Duration ( float64 ( target ) * ( 1.0 + tol ) )
l owSec := * targetSec * ( 1.0 - tol )
l egacyL owSec := * targetSec * ( 1.0 - tol )
h ighSec := * targetSec * ( 1.0 + tol )
legacyH ighSec := * targetSec * ( 1.0 + tol )
overlayNote := ""
overlayNote := ""
if strings . TrimSpace ( * configJSON ) != "" {
if strings . TrimSpace ( * configJSON ) != "" {
@ -161,8 +164,13 @@ func main() {
}
}
if * gridMode {
if * gridMode {
fmt . Printf ( "# balanceall: grid mode | iterations/cell=%d%s\n" , * iterations , overlayNote )
fmt . Printf ( "# balanceall: grid mode | iterations/cell=%d%s\n" , * iterations , overlayNote )
fmt . Printf ( "# duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n" ,
if * tieredTargets {
lowSec , highSec , 100 * hpLowGrid , 100 * hpHighGrid , * heroHpMid , * heroHpPP )
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" ,
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 )
fmt . Printf ( "# levels: heroLv==enemyLv for each L in [min_level..max_level]; gear: %d variants\n\n" , * gearVariants )
} else {
} 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" ,
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" ,
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 )
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 (
hpScale , atkScale , gfinal , ok := balanceArchetypeGrid (
tmpl , et , scenarios , * iterations , typeSeed ,
tmpl , et , scenarios , * iterations , typeSeed ,
lowSec , highSec , * targetSec , hpLowGrid , hpHighGrid , * minWinRate , * refinePasses ,
lowSec , highSec , targetSec Eff , hpLowGrid , hpHighGrid , * minWinRate , * refinePasses ,
)
)
if ! ok {
if ! ok {
fmt . Printf ( "## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n" , et )
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 ] ) + "…"
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).
// 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 ) {
func archetypeOrder ( ctx context . Context , templates map [ model . EnemyType ] model . Enemy , pool * pgxpool . Pool , enemyID int64 , enemyType string ) ( [ ] model . EnemyType , error ) {
if enemyID > 0 {
if enemyID > 0 {