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.

351 lines
10 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"
)
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
)
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"`
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"`
// 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 19: round(180 * 1.28^(L-1))
// L 1029: 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
}
// 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
}