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 }