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