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.

411 lines
13 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 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"`
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"`
// ExcursionKind is "roadside" | "adventure" during attractor-based excursions; empty otherwise.
ExcursionKind ExcursionKind `json:"excursionKind,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"`
// WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry).
WsDisconnectedAt *time.Time `json:"-"`
// 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 19: round(earlyBase * earlyScale^(L-1))
// L 1029: 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
}