You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
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; 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",
|
|
Name: "Steel Sword",
|
|
Subtype: "sword",
|
|
Rarity: model.RarityUncommon,
|
|
Ilvl: wIlvl,
|
|
BasePrimary: refGearBase,
|
|
PrimaryStat: wPrimary,
|
|
StatType: "attack",
|
|
SpeedModifier: 1.0,
|
|
CritChance: 0.05,
|
|
}
|
|
aPrimary := model.ScalePrimary(refGearBase, 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: refGearBase,
|
|
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
|
|
}
|
|
|
|
// PrepareHeroForAdminCombatSim returns a clone of h for the admin combat simulator: gear copied,
|
|
// all buffs and debuffs cleared, derived stats refreshed from base+gear only, HP set to max,
|
|
// state normalized to walking (fresh duel — ignores in-combat / resting flags on the source snapshot).
|
|
// Does not persist; does not mutate h.
|
|
// combatTimelineStart should match the start time passed to ResolveCombatToEndWithDuration (e.g. CombatSimDeterministicStart); if zero, time.Now() is used.
|
|
func PrepareHeroForAdminCombatSim(h *model.Hero, combatTimelineStart time.Time) *model.Hero {
|
|
hero := CloneHeroForCombatSim(h)
|
|
if hero == nil {
|
|
return nil
|
|
}
|
|
hero.Buffs = nil
|
|
hero.Debuffs = nil
|
|
hero.DebuffCatalog = nil
|
|
now := combatTimelineStart
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
hero.State = model.StateWalking
|
|
hero.RefreshDerivedCombatStats(now)
|
|
if hero.MaxHP <= 0 {
|
|
hero.MaxHP = 1
|
|
}
|
|
hero.HP = hero.MaxHP
|
|
return hero
|
|
}
|
|
|
|
// 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
|
|
}
|