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.

200 lines
5.9 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 uncommon sword + mail (deterministic, low noise).
ReferenceGearMedian ReferenceGearProfile = iota
// ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread).
ReferenceGearRolled
// ReferenceGearBaseline is common sword + common medium chest at ilvl == hero level (weakest fair reference).
ReferenceGearBaseline
// ReferenceGearMax is legendary sword + legendary medium chest at ilvl == hero level (strongest fair reference).
ReferenceGearMax
)
// 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)
}
var wRarity, aRarity model.Rarity
var refWeaponBase, refArmorBase int
switch profile {
case ReferenceGearBaseline:
wRarity, aRarity = model.RarityCommon, model.RarityCommon
refWeaponBase = 6 // postgear-nerf weapon base (~7×0.85)
refArmorBase = 3 // postgear-nerf chest (~4×0.7 for medium)
case ReferenceGearMax:
wRarity, aRarity = model.RarityLegendary, model.RarityLegendary
refWeaponBase = 6
refArmorBase = 3
default:
// Median, Rolled, unknown: uncommon + mid-tier ref bases aligned with gear migration.
wRarity, aRarity = model.RarityUncommon, model.RarityUncommon
refWeaponBase = 10 // ~12×0.85
refArmorBase = 8 // ~12×0.7
}
if profile == ReferenceGearBaseline || profile == ReferenceGearMax {
wName, aName := "Iron Sword", "Chainmail"
if profile == ReferenceGearMax {
wName, aName = "Soul Reaver", "Crown of Eternity"
}
if wf := model.GearFamilyByName(wName); wf != nil {
h.Gear[model.SlotMainHand] = model.NewGearItem(wf, wIlvl, wRarity)
}
if af := model.GearFamilyByName(aName); af != nil {
h.Gear[model.SlotChest] = model.NewGearItem(af, aIlvl, aRarity)
}
}
if h.Gear[model.SlotMainHand] == nil {
wPrimary := model.ScalePrimary(refWeaponBase, wIlvl, wRarity)
h.Gear[model.SlotMainHand] = &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
Name: "Steel Sword",
Subtype: "sword",
Rarity: wRarity,
Ilvl: wIlvl,
BasePrimary: refWeaponBase,
PrimaryStat: wPrimary,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
}
if h.Gear[model.SlotChest] == nil {
aPrimary := model.ScalePrimary(refArmorBase, aIlvl, aRarity)
h.Gear[model.SlotChest] = &model.GearItem{
Slot: model.SlotChest,
FormID: "gear.form.chest.medium",
Name: "Reinforced Mail",
Subtype: "medium",
Rarity: aRarity,
Ilvl: aIlvl,
BasePrimary: refArmorBase,
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
}