rebalance start
parent
86ffebf26a
commit
11d2c41e90
@ -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,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,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;
|
||||||
Loading…
Reference in New Issue