package model import ( "fmt" "time" "github.com/denisovdennis/autohero/internal/tuning" ) // BuffFreeChargesPerType defines per-buff charge limits per BuffChargePeriod (default 24h). var BuffFreeChargesPerType = map[BuffType]int{ BuffRush: 2, BuffRage: 1, BuffShield: 1, BuffLuck: 1, BuffResurrection: 1, BuffHeal: 2, BuffPowerPotion: 1, BuffWarCry: 1, } // BuffSubscriberChargesPerType defines higher per-buff caps for active subscribers (not a flat 2x). var BuffSubscriberChargesPerType = map[BuffType]int{ BuffRush: 3, BuffRage: 2, BuffShield: 2, BuffLuck: 2, BuffResurrection: 2, BuffHeal: 3, BuffPowerPotion: 1, BuffWarCry: 2, } func SubscriptionWeeklyPrice() int64 { return tuning.Get().SubscriptionWeeklyPriceRUB } func SubscriptionDurationRuntime() time.Duration { return time.Duration(tuning.Get().SubscriptionDurationMs) * time.Millisecond } func SubscriptionDurationLabel() string { d := SubscriptionDurationRuntime() if d <= 0 { return "0 hours" } if d%(24*time.Hour) == 0 { days := int64(d / (24 * time.Hour)) if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) } hours := int64(d / time.Hour) if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) } func BuffChargePeriod() time.Duration { return time.Duration(tuning.Get().BuffChargePeriodMs) * time.Millisecond } func FreeBuffActivationsPerPeriodRuntime() int { return int(tuning.Get().FreeBuffActivationsPerPeriod) } // RefreshSubscription checks if the subscription has expired and updates SubscriptionActive. // Returns true if the hero state was changed (caller should persist). func (h *Hero) RefreshSubscription(now time.Time) bool { if !h.SubscriptionActive { return false } if h.SubscriptionExpiresAt != nil && now.After(*h.SubscriptionExpiresAt) { h.SubscriptionActive = false h.SubscriptionExpiresAt = nil return true } return false } // ActivateSubscription sets the hero as a subscriber for SubscriptionDuration. // If already subscribed, extends from current expiry. func (h *Hero) ActivateSubscription(now time.Time) { if h.SubscriptionActive && h.SubscriptionExpiresAt != nil && h.SubscriptionExpiresAt.After(now) { // Extend from current expiry. extended := h.SubscriptionExpiresAt.Add(SubscriptionDurationRuntime()) h.SubscriptionExpiresAt = &extended } else { expires := now.Add(SubscriptionDurationRuntime()) h.SubscriptionExpiresAt = &expires } h.SubscriptionActive = true } // RevokeSubscription clears subscription immediately and clamps buff charges / revive uses to free-tier limits. func (h *Hero) RevokeSubscription(now time.Time) { h.SubscriptionActive = false h.SubscriptionExpiresAt = nil if h.BuffCharges != nil { for bt := range BuffFreeChargesPerType { key := string(bt) state, ok := h.BuffCharges[key] if !ok { continue } freeMax := BuffFreeChargesPerType[bt] if state.Remaining > freeMax { state.Remaining = freeMax h.BuffCharges[key] = state } } } maxR := h.MaxRevives() if h.ReviveCount > maxR { h.ReviveCount = maxR } h.EnsureBuffChargesPopulated(now) } // MaxBuffCharges returns the max charges for a buff type, considering subscription status. func (h *Hero) MaxBuffCharges(bt BuffType) int { if h.SubscriptionActive { if v, ok := BuffSubscriberChargesPerType[bt]; ok { return v } } if v, ok := BuffFreeChargesPerType[bt]; ok { return v } return FreeBuffActivationsPerPeriodRuntime() } // MaxRevives returns the max free revives per period (1 free, 2 for subscribers). func (h *Hero) MaxRevives() int { if h.SubscriptionActive { return int(tuning.Get().MaxRevivesSubscriber) } return int(tuning.Get().MaxRevivesFree) } // ApplyBuffQuotaRollover refills free buff charges when the 24h window has passed. // Returns true if the hero was mutated (caller may persist). // Deprecated: kept for backward compat with the shared counter. New code should // use GetBuffCharges / ConsumeBuffCharge which handle rollover inline. func (h *Hero) ApplyBuffQuotaRollover(now time.Time) bool { if h.SubscriptionActive { return false } if h.BuffQuotaPeriodEnd == nil { return false } changed := false for now.After(*h.BuffQuotaPeriodEnd) { h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriodRuntime() next := h.BuffQuotaPeriodEnd.Add(BuffChargePeriod()) h.BuffQuotaPeriodEnd = &next changed = true } return changed } // GetBuffCharges returns the current charge state for a specific buff type, // rolling over the 24h window if expired. func (h *Hero) GetBuffCharges(bt BuffType, now time.Time) BuffChargeState { if h.BuffCharges == nil { h.BuffCharges = make(map[string]BuffChargeState) } maxCharges := h.MaxBuffCharges(bt) state, exists := h.BuffCharges[string(bt)] if !exists { // First access for this buff type — initialize with full charges. pe := now.Add(BuffChargePeriod()) state = BuffChargeState{ Remaining: maxCharges, PeriodEnd: &pe, } h.BuffCharges[string(bt)] = state return state } // Roll over if the period has expired. if state.PeriodEnd != nil && now.After(*state.PeriodEnd) { for state.PeriodEnd != nil && now.After(*state.PeriodEnd) { next := state.PeriodEnd.Add(BuffChargePeriod()) state.PeriodEnd = &next } state.Remaining = maxCharges h.BuffCharges[string(bt)] = state } return state } // ConsumeBuffCharge decrements one free charge for the specific buff type. // Returns an error if no charges remain. func (h *Hero) ConsumeBuffCharge(bt BuffType, now time.Time) error { state := h.GetBuffCharges(bt, now) if state.Remaining <= 0 { periodStr := "unknown" if state.PeriodEnd != nil { periodStr = state.PeriodEnd.Format(time.RFC3339) } return fmt.Errorf( "no free %s charges left; next refresh at %s", string(bt), periodStr, ) } state.Remaining-- h.BuffCharges[string(bt)] = state // Keep legacy counter roughly in sync. h.BuffFreeChargesRemaining-- if h.BuffFreeChargesRemaining < 0 { h.BuffFreeChargesRemaining = 0 } return nil } // RefundBuffCharge increments one charge back for the specific buff type. func (h *Hero) RefundBuffCharge(bt BuffType) { if h.BuffCharges == nil { return } state, exists := h.BuffCharges[string(bt)] if !exists { return } maxCap := h.MaxBuffCharges(bt) state.Remaining++ if state.Remaining > maxCap { state.Remaining = maxCap } h.BuffCharges[string(bt)] = state // Keep legacy counter roughly in sync. h.BuffFreeChargesRemaining++ } // ResetBuffCharges resets charges to max. If bt is nil, resets ALL buff types. // If bt is non-nil, resets only that buff type. func (h *Hero) ResetBuffCharges(bt *BuffType, now time.Time) { if h.BuffCharges == nil { h.BuffCharges = make(map[string]BuffChargeState) } pe := now.Add(BuffChargePeriod()) if bt != nil { maxCharges := h.MaxBuffCharges(*bt) h.BuffCharges[string(*bt)] = BuffChargeState{ Remaining: maxCharges, PeriodEnd: &pe, } return } // Reset ALL buff types. for buffType := range BuffFreeChargesPerType { h.BuffCharges[string(buffType)] = BuffChargeState{ Remaining: h.MaxBuffCharges(buffType), PeriodEnd: &pe, } } // Also reset legacy counter. h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriodRuntime() h.BuffQuotaPeriodEnd = &pe } // EnsureBuffChargesPopulated initializes BuffCharges for all buff types if the map // is empty. Returns true if the map was freshly populated (caller should persist). func (h *Hero) EnsureBuffChargesPopulated(now time.Time) bool { if h.BuffCharges == nil { h.BuffCharges = make(map[string]BuffChargeState) } if len(h.BuffCharges) == 0 { pe := now.Add(BuffChargePeriod()) if h.BuffQuotaPeriodEnd != nil { pe = *h.BuffQuotaPeriodEnd } for bt := range BuffFreeChargesPerType { h.BuffCharges[string(bt)] = BuffChargeState{ Remaining: h.MaxBuffCharges(bt), PeriodEnd: &pe, } } return true } return false }