|
|
package model
|
|
|
|
|
|
import (
|
|
|
"math"
|
|
|
"time"
|
|
|
)
|
|
|
|
|
|
const (
|
|
|
// AgilityCoef follows the project combat specification (agility contribution to APS).
|
|
|
AgilityCoef = 0.03
|
|
|
// MaxAttackSpeed enforces the target cap of ~4 attacks/sec.
|
|
|
MaxAttackSpeed = 4.0
|
|
|
// 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"`
|
|
|
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"`
|
|
|
|
|
|
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
|
|
|
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. Phase-based curve (spec §9) — v3 scales bases ×10 vs v2 for ~10×
|
|
|
// slower leveling when paired with reduced kill XP:
|
|
|
//
|
|
|
// L 1–9: round(180 * 1.28^(L-1))
|
|
|
// L 10–29: round(1450 * 1.15^(L-10))
|
|
|
// L 30+: round(23000 * 1.10^(L-30))
|
|
|
func XPToNextLevel(level int) int64 {
|
|
|
if level < 1 {
|
|
|
level = 1
|
|
|
}
|
|
|
switch {
|
|
|
case level <= 9:
|
|
|
return int64(math.Round(180 * math.Pow(1.28, float64(level-1))))
|
|
|
case level <= 29:
|
|
|
return int64(math.Round(1450 * math.Pow(1.15, float64(level-10))))
|
|
|
default:
|
|
|
return int64(math.Round(23000 * math.Pow(1.10, 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).
|
|
|
if h.Level%10 == 0 {
|
|
|
h.MaxHP += 1 + h.Constitution/6
|
|
|
}
|
|
|
if h.Level%30 == 0 {
|
|
|
h.Attack++
|
|
|
}
|
|
|
if h.Level%30 == 0 {
|
|
|
h.Defense++
|
|
|
}
|
|
|
|
|
|
if h.Level%40 == 0 {
|
|
|
h.Strength++
|
|
|
}
|
|
|
if h.Level%50 == 0 {
|
|
|
h.Constitution++
|
|
|
}
|
|
|
if h.Level%60 == 0 {
|
|
|
h.Agility++
|
|
|
}
|
|
|
if h.Level%100 == 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.
|
|
|
speed := h.Speed + float64(effectiveAgility)*AgilityCoef
|
|
|
if speed < 0.1 {
|
|
|
speed = 0.1
|
|
|
}
|
|
|
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 > MaxAttackSpeed {
|
|
|
speed = MaxAttackSpeed
|
|
|
}
|
|
|
if speed < 0.1 {
|
|
|
speed = 0.1
|
|
|
}
|
|
|
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
|
|
|
effectiveConstitution := h.Constitution + bonuses.constitutionBonus
|
|
|
if chest := h.Gear[SlotChest]; chest != nil {
|
|
|
effectiveAgility += chest.AgilityBonus
|
|
|
}
|
|
|
|
|
|
atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + effectiveConstitution/8
|
|
|
if weapon := h.Gear[SlotMainHand]; weapon != nil {
|
|
|
atk += weapon.PrimaryStat
|
|
|
}
|
|
|
atkF := float64(atk)
|
|
|
atkF *= bonuses.attackMultiplier
|
|
|
// Apply weaken debuff.
|
|
|
for _, ad := range h.Debuffs {
|
|
|
if ad.IsExpired(now) {
|
|
|
continue
|
|
|
}
|
|
|
if ad.Debuff.Type == DebuffWeaken {
|
|
|
atkF *= (1 - ad.Debuff.Magnitude) // -30% outgoing damage
|
|
|
}
|
|
|
}
|
|
|
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
|
|
|
}
|
|
|
|
|
|
def := h.Defense + effectiveConstitution/4 + effectiveAgility/8
|
|
|
if chest := h.Gear[SlotChest]; chest != nil {
|
|
|
def += chest.PrimaryStat
|
|
|
}
|
|
|
def = int(float64(def) * bonuses.defenseMultiplier)
|
|
|
if def < 0 {
|
|
|
def = 0
|
|
|
}
|
|
|
return def
|
|
|
}
|
|
|
|
|
|
// 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
|
|
|
}
|