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

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
}