package model import ( "math" "time" "github.com/denisovdennis/autohero/internal/tuning" ) const ( // MaxInventorySlots is the maximum unequipped gear items carried at once. MaxInventorySlots = 40 ) type Hero struct { ID int64 `json:"id"` TelegramID int64 `json:"telegramId"` Name string `json:"name"` HP int `json:"hp"` MaxHP int `json:"maxHp"` Attack int `json:"attack"` Defense int `json:"defense"` Speed float64 `json:"speed"` // attacks per second base rate Strength int `json:"strength"` Constitution int `json:"constitution"` Agility int `json:"agility"` Luck int `json:"luck"` State GameState `json:"state"` WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat Gear map[EquipmentSlot]*GearItem `json:"gear"` // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. Inventory []*GearItem `json:"inventory,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"` // DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted. DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"` Gold int64 `json:"gold"` XP int64 `json:"xp"` Level int `json:"level"` XPToNext int64 `json:"xpToNext"` AttackSpeed float64 `json:"attackSpeed,omitempty"` AttackPower int `json:"attackPower,omitempty"` DefensePower int `json:"defensePower,omitempty"` MoveSpeed float64 `json:"moveSpeed,omitempty"` PositionX float64 `json:"positionX"` PositionY float64 `json:"positionY"` Potions int `json:"potions"` ReviveCount int `json:"reviveCount"` SubscriptionActive bool `json:"subscriptionActive"` SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"` // Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead. BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` // Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead. BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"` // BuffCharges holds per-buff-type free charge state (remaining count + period window). BuffCharges map[string]BuffChargeState `json:"buffCharges"` // Stat tracking for achievements. TotalKills int `json:"totalKills"` EliteKills int `json:"eliteKills"` TotalDeaths int `json:"totalDeaths"` KillsSinceDeath int `json:"killsSinceDeath"` LegendaryDrops int `json:"legendaryDrops"` // Movement state (persisted to DB for reconnect recovery). CurrentTownID *int64 `json:"currentTownId,omitempty"` DestinationTownID *int64 `json:"destinationTownId,omitempty"` MoveState string `json:"moveState"` RestKind RestKind `json:"restKind,omitempty"` // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` // TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only). TownPause *TownPausePersisted `json:"-"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). ChangelogAckVersion string `json:"-"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // BuffChargeState tracks the remaining free charges and period window for a single buff type. type BuffChargeState struct { Remaining int `json:"remaining"` PeriodEnd *time.Time `json:"periodEnd,omitempty"` } // XPToNextLevel returns the XP delta required to advance from the given level // to level+1. Early band uses a nonlinear step (~100 kills at 1 XP/kill for L1→2, // ~150 for L2→3, ~225 for L3→4 with defaults). Mid/late bands use tuning bases. // // L 1–9: round(earlyBase * earlyScale^(L-1)) // L 10–29: round(midBase * midScale^(L-10)) // L 30+: round(lateBase * lateScale^(L-30)) func XPToNextLevel(level int) int64 { cfg := tuning.Get() if level < 1 { level = 1 } switch { case level <= 9: return int64(math.Round(cfg.XPCurveEarlyBase * math.Pow(cfg.XPCurveEarlyScale, float64(level-1)))) case level <= 29: return int64(math.Round(cfg.XPCurveMidBase * math.Pow(cfg.XPCurveMidScale, float64(level-10)))) default: return int64(math.Round(cfg.XPCurveLateBase * math.Pow(cfg.XPCurveLateScale, float64(level-30)))) } } // CanLevelUp returns true if the hero has enough XP to advance to the next level. func (h *Hero) CanLevelUp() bool { return h.XP >= XPToNextLevel(h.Level) } // LevelUp advances the hero to the next level, deducts the required XP, and // applies stat growth. HP is NOT restored on level-up (spec §3.3). // Returns true if the hero leveled up, false if insufficient XP. func (h *Hero) LevelUp() bool { if !h.CanLevelUp() { return false } h.XP -= XPToNextLevel(h.Level) h.Level++ // v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3). cfg := tuning.Get() if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 { hpBase := cfg.LevelUpHpBase if hpBase <= 0 { hpBase = 1 } h.MaxHP += hpBase + h.Constitution/6 } if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { h.Attack++ } if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 { h.Defense++ } if cfg.LevelUpSTREvery > 0 && h.Level%int(cfg.LevelUpSTREvery) == 0 { h.Strength++ } if cfg.LevelUpCONEvery > 0 && h.Level%int(cfg.LevelUpCONEvery) == 0 { h.Constitution++ } if cfg.LevelUpAGIEvery > 0 && h.Level%int(cfg.LevelUpAGIEvery) == 0 { h.Agility++ } if cfg.LevelUpLUCKEvery > 0 && h.Level%int(cfg.LevelUpLUCKEvery) == 0 { h.Luck++ } return true } type statBonuses struct { strengthBonus int constitutionBonus int agilityBonus int attackMultiplier float64 speedMultiplier float64 defenseMultiplier float64 critChanceBonus float64 critDamageBonus float64 blockChanceBonus float64 movementMultiplier float64 } func (h *Hero) activeStatBonuses(now time.Time) statBonuses { out := statBonuses{ strengthBonus: 0, constitutionBonus: 0, agilityBonus: 0, attackMultiplier: 1.0, speedMultiplier: 1.0, defenseMultiplier: 1.0, critChanceBonus: 0.0, critDamageBonus: 0.0, blockChanceBonus: 0.0, movementMultiplier: 1.0, } for _, ab := range h.Buffs { if ab.IsExpired(now) { continue } switch ab.Buff.Type { case BuffRush: out.movementMultiplier *= (1 + ab.Buff.Magnitude) case BuffRage: out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.strengthBonus += 10 case BuffPowerPotion: out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.strengthBonus += 12 case BuffWarCry: out.speedMultiplier *= (1 + ab.Buff.Magnitude) out.strengthBonus += 6 out.agilityBonus += 6 case BuffShield: out.constitutionBonus += 10 out.defenseMultiplier *= (1 + ab.Buff.Magnitude) } } return out } // EffectiveSpeed returns the hero's attack speed after weapon, armor, buff, and debuff modifiers. func (h *Hero) EffectiveSpeed() float64 { return h.EffectiveSpeedAt(time.Now()) } func (h *Hero) EffectiveSpeedAt(now time.Time) float64 { bonuses := h.activeStatBonuses(now) effectiveAgility := h.Agility + bonuses.agilityBonus if chest := h.Gear[SlotChest]; chest != nil { effectiveAgility += chest.AgilityBonus } // Base attack speed derives from base speed + agility coefficient. cfg := tuning.Get() speed := h.Speed + float64(effectiveAgility)*cfg.AgilityCoef if speed < cfg.MinAttackSpeed { speed = cfg.MinAttackSpeed } if weapon := h.Gear[SlotMainHand]; weapon != nil { speed *= weapon.SpeedModifier } if chest := h.Gear[SlotChest]; chest != nil { speed *= chest.SpeedModifier } speed *= bonuses.speedMultiplier // Apply debuffs that reduce attack speed. // Slow is movement-only per spec §7.2 and does not affect attack speed. for _, ad := range h.Debuffs { if ad.IsExpired(now) { continue } switch ad.Debuff.Type { case DebuffFreeze: speed *= (1 - ad.Debuff.Magnitude) // -50% attack speed case DebuffIceSlow: speed *= (1 - ad.Debuff.Magnitude) // -20% attack speed (Ice Guardian) } } if speed > cfg.MaxAttackSpeed { speed = cfg.MaxAttackSpeed } if speed < cfg.MinAttackSpeed { speed = cfg.MinAttackSpeed } return speed } // EffectiveAttack returns the hero's attack after weapon, buff, and debuff modifiers. func (h *Hero) EffectiveAttack() int { return h.EffectiveAttackAt(time.Now()) } func (h *Hero) EffectiveAttackAt(now time.Time) int { bonuses := h.activeStatBonuses(now) effectiveStrength := h.Strength + bonuses.strengthBonus effectiveAgility := h.Agility + bonuses.agilityBonus if chest := h.Gear[SlotChest]; chest != nil { effectiveAgility += chest.AgilityBonus } gearAttack, _ := h.gearPrimaryBonuses() atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack atkF := float64(atk) atkF *= bonuses.attackMultiplier if atkF < 1 { atkF = 1 } return int(atkF) } // EffectiveDefense returns the hero's defense after armor and buff modifiers. func (h *Hero) EffectiveDefense() int { return h.EffectiveDefenseAt(time.Now()) } func (h *Hero) EffectiveDefenseAt(now time.Time) int { bonuses := h.activeStatBonuses(now) effectiveConstitution := h.Constitution + bonuses.constitutionBonus effectiveAgility := h.Agility + bonuses.agilityBonus if chest := h.Gear[SlotChest]; chest != nil { effectiveAgility += chest.AgilityBonus } _, gearDefense := h.gearPrimaryBonuses() def := h.Defense + effectiveConstitution + effectiveAgility/4 + gearDefense def = int(float64(def) * bonuses.defenseMultiplier) if def < 0 { def = 0 } return def } // EffectiveBlockChance returns the hero's block chance after buffs and defense scaling. func (h *Hero) EffectiveBlockChance(now time.Time) float64 { cfg := tuning.Get() bonuses := h.activeStatBonuses(now) chance := float64(h.EffectiveDefenseAt(now))*cfg.HeroBlockChancePerDefense + bonuses.blockChanceBonus if chance < 0 { chance = 0 } if cfg.HeroBlockChanceCap > 0 && chance > cfg.HeroBlockChanceCap { chance = cfg.HeroBlockChanceCap } return chance } // gearPrimaryBonuses sums primary stats from all equipped gear by statType. // Mixed items split their primary stat between attack and defense. func (h *Hero) gearPrimaryBonuses() (attackBonus int, defenseBonus int) { if h.Gear == nil { return 0, 0 } for _, item := range h.Gear { if item == nil { continue } switch item.StatType { case "attack": attackBonus += item.PrimaryStat case "defense": defenseBonus += item.PrimaryStat case "mixed": half := item.PrimaryStat / 2 attackBonus += half defenseBonus += item.PrimaryStat - half } } return attackBonus, defenseBonus } // MovementSpeedMultiplier returns the hero's movement speed modifier (1.0 = normal). // Rush buff and Slow debuff affect movement, not attack speed, per spec §7. func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 { mult := 1.0 for _, ab := range h.Buffs { if ab.IsExpired(now) { continue } if ab.Buff.Type == BuffRush { mult *= (1 + ab.Buff.Magnitude) // +50% movement } } for _, ad := range h.Debuffs { if ad.IsExpired(now) { continue } if ad.Debuff.Type == DebuffSlow { mult *= (1 - ad.Debuff.Magnitude) // -40% movement } } if mult < 0.1 { mult = 0.1 } return mult } // EnsureGearMap guarantees Gear is a non-nil map so JSON encodes "gear":{} instead of null // (clients treat null as missing equipment). func (h *Hero) EnsureGearMap() { if h.Gear == nil { h.Gear = make(map[EquipmentSlot]*GearItem) } } // EnsureInventorySlice guarantees Inventory is a non-nil slice for append/count. func (h *Hero) EnsureInventorySlice() { if h.Inventory == nil { h.Inventory = []*GearItem{} } } // RefreshDerivedCombatStats updates exported derived combat fields for API/state usage. func (h *Hero) RefreshDerivedCombatStats(now time.Time) { h.XPToNext = XPToNextLevel(h.Level) h.AttackSpeed = h.EffectiveSpeedAt(now) h.AttackPower = h.EffectiveAttackAt(now) h.DefensePower = h.EffectiveDefenseAt(now) h.MoveSpeed = h.MovementSpeedMultiplier(now) } // CombatRatingAt computes a single-number combat effectiveness score used by // the auto-equip system to decide whether new gear is an upgrade. func (h *Hero) CombatRatingAt(now time.Time) float64 { return float64(h.EffectiveAttackAt(now))*h.EffectiveSpeedAt(now) + float64(h.EffectiveDefenseAt(now))*0.35 } // IsAlive returns true if the hero has HP remaining. func (h *Hero) IsAlive() bool { return h.HP > 0 } // IsStunned returns true if the hero currently has an active stun debuff. func (h *Hero) IsStunned(now time.Time) bool { for _, ad := range h.Debuffs { if ad.Debuff.Type == DebuffStun && !ad.IsExpired(now) { return true } } return false }