package tuning import ( "context" "encoding/json" "log/slog" "sync/atomic" "time" ) // Values contains runtime-tunable gameplay knobs loaded from DB. // Missing JSON fields keep default values. type Values struct { EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"` EncounterActivityBase float64 `json:"encounterActivityBase"` BaseMoveSpeed float64 `json:"baseMoveSpeed"` MovementTickRateMs int64 `json:"movementTickRateMs"` PositionSyncRateMs int64 `json:"positionSyncRateMs"` TownRestMinMs int64 `json:"townRestMinMs"` TownRestMaxMs int64 `json:"townRestMaxMs"` TownRestHPPerS float64 `json:"townRestHpPerSecond"` TownArrivalRadius float64 `json:"townArrivalRadius"` TownNPCVisitChance float64 `json:"townNpcVisitChance"` // TownNPCApproachChance: second roll after a visit timer fires — whether the hero commits to walking // toward the next queued NPC. 1.0 = same as legacy (only TownNPCVisitChance gates approach). TownNPCApproachChance float64 `json:"townNpcApproachChance"` // TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services // (buy potion, full heal, accept a quest) instead of walking past. TownNPCInteractChance float64 `json:"townNpcInteractChance"` TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"` TownNPCPauseMs int64 `json:"townNpcPauseMs"` TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` // TownAfterNPCRestChance: after the NPC tour, at town center — probability of a full town rest // (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait. TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"` MerchantCostBase int64 `json:"merchantCostBase"` MerchantCostPerLevel int64 `json:"merchantCostPerLevel"` MerchantTownAutoSellShare float64 `json:"merchantTownAutoSellShare"` MonsterEncounterWeightBase float64 `json:"monsterEncounterWeightBase"` MonsterEncounterWeightWildBonus float64 `json:"monsterEncounterWeightWildBonus"` MerchantEncounterWeightBase float64 `json:"merchantEncounterWeightBase"` MerchantEncounterWeightRoadBonus float64 `json:"merchantEncounterWeightRoadBonus"` LootChanceCommon float64 `json:"lootChanceCommon"` LootChanceUncommon float64 `json:"lootChanceUncommon"` LootChanceRare float64 `json:"lootChanceRare"` LootChanceEpic float64 `json:"lootChanceEpic"` LootChanceLegendary float64 `json:"lootChanceLegendary"` GoldLootScale float64 `json:"goldLootScale"` // GoldDropChance is P(at least one gold line) per kill before luck; rolled first, then rarity/amount. GoldDropChance float64 `json:"goldDropChance"` PotionDropChance float64 `json:"potionDropChance"` EquipmentDropBase float64 `json:"equipmentDropBase"` GoldCommonMin int64 `json:"goldCommonMin"` GoldCommonMax int64 `json:"goldCommonMax"` GoldUncommonMin int64 `json:"goldUncommonMin"` GoldUncommonMax int64 `json:"goldUncommonMax"` GoldRareMin int64 `json:"goldRareMin"` GoldRareMax int64 `json:"goldRareMax"` GoldEpicMin int64 `json:"goldEpicMin"` GoldEpicMax int64 `json:"goldEpicMax"` GoldLegendaryMin int64 `json:"goldLegendaryMin"` GoldLegendaryMax int64 `json:"goldLegendaryMax"` AutoSellCommon int64 `json:"autoSellCommon"` AutoSellUncommon int64 `json:"autoSellUncommon"` AutoSellRare int64 `json:"autoSellRare"` AutoSellEpic int64 `json:"autoSellEpic"` AutoSellLegendary int64 `json:"autoSellLegendary"` RESTEncounterCooldownMs int64 `json:"restEncounterCooldownMs"` RESTEncounterNPCChance float64 `json:"restEncounterNpcChance"` NPCCostHeal int64 `json:"npcCostHeal"` NPCCostPotion int64 `json:"npcCostPotion"` NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"` // QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests). QuestOffersPerNPC int `json:"questOffersPerNPC"` // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). QuestOfferRefreshHours int `json:"questOfferRefreshHours"` CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMax float64 `json:"combatDamageRollMax"` // EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy). EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` HeroCritChanceCap float64 `json:"heroCritChanceCap"` HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"` HeroBlockChanceCap float64 `json:"heroBlockChanceCap"` EnemyBurstEveryN int64 `json:"enemyBurstEveryN"` EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyChainEveryN int64 `json:"enemyChainEveryN"` EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` DebuffProcBurn float64 `json:"debuffProcBurn"` DebuffProcPoison float64 `json:"debuffProcPoison"` DebuffProcSlow float64 `json:"debuffProcSlow"` DebuffProcStun float64 `json:"debuffProcStun"` DebuffProcFreeze float64 `json:"debuffProcFreeze"` DebuffProcIceSlow float64 `json:"debuffProcIceSlow"` EnemyRegenDefault float64 `json:"enemyRegenDefault"` EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"` EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"` EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"` SummonCycleSeconds int64 `json:"summonCycleSeconds"` SummonDamageDivisor int64 `json:"summonDamageDivisor"` LuckBuffMultiplier float64 `json:"luckBuffMultiplier"` MinAttackIntervalMs int64 `json:"minAttackIntervalMs"` CombatPaceMultiplier int64 `json:"combatPaceMultiplier"` PotionHealPercent float64 `json:"potionHealPercent"` PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"` ReviveHpPercent float64 `json:"reviveHpPercent"` AutoReviveAfterMs int64 `json:"autoReviveAfterMs"` XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"` XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"` XPCurveMidBase float64 `json:"xpCurveMidBase"` XPCurveMidScale float64 `json:"xpCurveMidScale"` XPCurveLateBase float64 `json:"xpCurveLateBase"` XPCurveLateScale float64 `json:"xpCurveLateScale"` LevelUpHPEvery int64 `json:"levelUpHpEvery"` LevelUpATKEvery int64 `json:"levelUpAtkEvery"` LevelUpDEFEvery int64 `json:"levelUpDefEvery"` LevelUpSTREvery int64 `json:"levelUpStrEvery"` LevelUpCONEvery int64 `json:"levelUpConEvery"` LevelUpAGIEvery int64 `json:"levelUpAgiEvery"` LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"` AgilityCoef float64 `json:"agilityCoef"` MaxAttackSpeed float64 `json:"maxAttackSpeed"` MinAttackSpeed float64 `json:"minAttackSpeed"` IlvlFactorSlope float64 `json:"ilvlFactorSlope"` RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"` RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"` RarityMultiplierRare float64 `json:"rarityMultiplierRare"` RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"` RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"` RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"` RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"` BuffChargePeriodMs int64 `json:"buffChargePeriodMs"` FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"` SubscriptionDurationMs int64 `json:"subscriptionDurationMs"` SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"` BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"` ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"` MaxRevivesFree int64 `json:"maxRevivesFree"` MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"` EnemyScaleBandHP float64 `json:"enemyScaleBandHp"` EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"` EnemyScaleBandATK float64 `json:"enemyScaleBandAtk"` EnemyScaleOvercapATK float64 `json:"enemyScaleOvercapAtk"` EnemyScaleBandDEF float64 `json:"enemyScaleBandDef"` EnemyScaleOvercapDEF float64 `json:"enemyScaleOvercapDef"` EnemyScaleBandXP float64 `json:"enemyScaleBandXp"` EnemyScaleOvercapXP float64 `json:"enemyScaleOvercapXp"` EnemyScaleBandGold float64 `json:"enemyScaleBandGold"` EnemyScaleOvercapGold float64 `json:"enemyScaleOvercapGold"` AutoEquipThreshold float64 `json:"autoEquipThreshold"` LootHistoryLimit int64 `json:"lootHistoryLimit"` // --- Adventure / excursion (mini-adventure off-road) --- // AdventureStartChance is the per-tick probability of starting an adventure while walking. // With 500ms ticks and ~50% walking uptime, 0.0001 ≈ 3 adventures per 8 h. AdventureStartChance float64 `json:"adventureStartChance"` // AdventureCooldownMs is the minimum wall-time between two adventure sessions. AdventureCooldownMs int64 `json:"adventureCooldownMs"` // AdventureOutDurationMs is how long the "out" phase lasts (hero moves off-road into forest). AdventureOutDurationMs int64 `json:"adventureOutDurationMs"` // AdventureWildMinMs / AdventureWildMaxMs define the random range for the "wild" phase // (encounters in the forest). Total adventure ≈ out + wild + return. AdventureWildMinMs int64 `json:"adventureWildMinMs"` AdventureWildMaxMs int64 `json:"adventureWildMaxMs"` // AdventureReturnDurationMs is how long the "return" phase lasts (hero walks back to road). AdventureReturnDurationMs int64 `json:"adventureReturnDurationMs"` // AdventureDepthWorldUnits is the max perpendicular offset from road during an adventure. AdventureDepthWorldUnits float64 `json:"adventureDepthWorldUnits"` // AdventureEncounterCooldownMs is the encounter cooldown while in the wild/return phases. AdventureEncounterCooldownMs int64 `json:"adventureEncounterCooldownMs"` // AdventureReturnEncounterEnabled allows encounters during the return phase. AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"` // AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return. AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"` // --- HP-based rest triggers --- // LowHpThreshold is the HP/MaxHP fraction below which rest may trigger (0..1). LowHpThreshold float64 `json:"lowHpThreshold"` // RoadsideRestExitHp is the HP/MaxHP fraction at which roadside rest ends early (0..1). RoadsideRestExitHp float64 `json:"roadsideRestExitHp"` // AdventureRestTargetHp is the HP/MaxHP fraction at which adventure inline rest ends (0..1). AdventureRestTargetHp float64 `json:"adventureRestTargetHp"` // RoadsideRestMinMs is the minimum duration for a roadside rest period. RoadsideRestMinMs int64 `json:"roadsideRestMinMs"` // RoadsideRestMaxMs is the maximum duration for a roadside rest period. RoadsideRestMaxMs int64 `json:"roadsideRestMaxMs"` // RoadsideRestHpPerS is the HP/MaxHP fraction healed per second during roadside rest. RoadsideRestHpPerS float64 `json:"roadsideRestHpPerSecond"` // AdventureRestHpPerS is the HP/MaxHP fraction healed per second during adventure inline rest. AdventureRestHpPerS float64 `json:"adventureRestHpPerSecond"` // RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest. RoadsideRestDepthWorldUnits float64 `json:"roadsideRestDepthWorldUnits"` } func DefaultValues() Values { return Values{ EncounterCooldownBaseMs: 12_000, EncounterActivityBase: 0.035, BaseMoveSpeed: 2.0, MovementTickRateMs: 500, PositionSyncRateMs: 10_000, TownRestMinMs: 5 * 60 * 1000, TownRestMaxMs: 20 * 60 * 1000, TownRestHPPerS: 0.002, TownArrivalRadius: 0.5, TownNPCVisitChance: 0.78, TownNPCApproachChance: 1.0, TownNPCInteractChance: 0.65, TownNPCRollMinMs: 800, TownNPCRollMaxMs: 2600, TownNPCRetryMs: 450, TownNPCPauseMs: 30_000, TownNPCLogIntervalMs: 5_000, TownNPCWalkSpeed: 3.0, TownNPCStandoffWorld: 0.65, TownAfterNPCRestChance: 0.78, WanderingMerchantPromptTimeoutMs: 15_000, MerchantCostBase: 20, MerchantCostPerLevel: 5, MerchantTownAutoSellShare: 0.30, MonsterEncounterWeightBase: 0.62, MonsterEncounterWeightWildBonus: 0.18, MerchantEncounterWeightBase: 0.02, MerchantEncounterWeightRoadBonus: 0.05, LootChanceCommon: 0.40, LootChanceUncommon: 0.10, LootChanceRare: 0.02, LootChanceEpic: 0.003, LootChanceLegendary: 0.0005, GoldLootScale: 0.5, GoldDropChance: 0.90, PotionDropChance: 0.05, EquipmentDropBase: 0.15, GoldCommonMin: 0, GoldCommonMax: 5, GoldUncommonMin: 6, GoldUncommonMax: 20, GoldRareMin: 21, GoldRareMax: 50, GoldEpicMin: 51, GoldEpicMax: 120, GoldLegendaryMin: 121, GoldLegendaryMax: 300, AutoSellCommon: 3, AutoSellUncommon: 8, AutoSellRare: 20, AutoSellEpic: 60, AutoSellLegendary: 180, RESTEncounterCooldownMs: 16_000, RESTEncounterNPCChance: 0.10, NPCCostHeal: 100, NPCCostPotion: 50, NPCCostNearbyRadius: 3.0, QuestOffersPerNPC: 2, QuestOfferRefreshHours: 2, CombatDamageScale: 0.35, CombatDamageRollMin: 0.60, CombatDamageRollMax: 1.10, EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, EnemyDodgeChance: 0.20, EnemyCriticalMinChance: 0.10, EnemyCritChanceCap: 0.20, HeroCritChanceCap: 0.12, HeroBlockChancePerDefense: 0.0025, HeroBlockChanceCap: 0.20, EnemyBurstEveryN: 3, EnemyBurstMultiplier: 1.5, EnemyChainEveryN: 6, EnemyChainMultiplier: 3.0, DebuffProcBurn: 0.30, DebuffProcPoison: 0.10, DebuffProcSlow: 0.25, DebuffProcStun: 0.25, DebuffProcFreeze: 0.20, DebuffProcIceSlow: 0.20, EnemyRegenDefault: DefaultEnemyRegenDefault, EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing, EnemyRegenForestWarden: DefaultEnemyRegenForestWarden, EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, SummonCycleSeconds: 15, SummonDamageDivisor: 4, LuckBuffMultiplier: 1.75, MinAttackIntervalMs: 250, CombatPaceMultiplier: 5, PotionHealPercent: 0.30, PotionAutoUseThreshold: 0.30, ReviveHpPercent: 0.50, AutoReviveAfterMs: int64(time.Hour / time.Millisecond), XPCurveEarlyBase: 180, XPCurveEarlyScale: 1.28, XPCurveMidBase: 1450, XPCurveMidScale: 1.15, XPCurveLateBase: 23000, XPCurveLateScale: 1.10, LevelUpHPEvery: 10, LevelUpATKEvery: 30, LevelUpDEFEvery: 30, LevelUpSTREvery: 40, LevelUpCONEvery: 50, LevelUpAGIEvery: 60, LevelUpLUCKEvery: 100, AgilityCoef: 0.03, MaxAttackSpeed: 4.0, MinAttackSpeed: 0.1, IlvlFactorSlope: 0.03, RarityMultiplierCommon: 1.00, RarityMultiplierUncommon: 1.12, RarityMultiplierRare: 1.30, RarityMultiplierEpic: 1.52, RarityMultiplierLegendary: 1.78, RollIlvlEliteBaseChance: 0.4, RollIlvlElitePlusOneChance: 0.4, BuffChargePeriodMs: 24 * 60 * 60 * 1000, FreeBuffActivationsPerPeriod: 2, SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000, SubscriptionWeeklyPriceRUB: 299, BuffRefillPriceRUB: 50, ResurrectionRefillPriceRUB: 150, MaxRevivesFree: 1, MaxRevivesSubscriber: 2, EnemyScaleBandHP: 0.05, EnemyScaleOvercapHP: 0.025, EnemyScaleBandATK: 0.035, EnemyScaleOvercapATK: 0.018, EnemyScaleBandDEF: 0.035, EnemyScaleOvercapDEF: 0.018, EnemyScaleBandXP: 0.05, EnemyScaleOvercapXP: 0.03, EnemyScaleBandGold: 0.05, EnemyScaleOvercapGold: 0.025, AutoEquipThreshold: 1.03, LootHistoryLimit: 50, AdventureStartChance: 0.0001, AdventureCooldownMs: 300_000, AdventureOutDurationMs: 20_000, AdventureWildMinMs: 560_000, AdventureWildMaxMs: 2_960_000, AdventureReturnDurationMs: 20_000, AdventureDepthWorldUnits: 40.0, AdventureEncounterCooldownMs: 6_000, AdventureReturnEncounterEnabled: true, AdventureReturnWildnessMin: 0.35, LowHpThreshold: 0.25, RoadsideRestExitHp: 0.70, AdventureRestTargetHp: 0.70, RoadsideRestMinMs: 240_000, RoadsideRestMaxMs: 600_000, RoadsideRestHpPerS: 0.003, AdventureRestHpPerS: 0.004, RoadsideRestDepthWorldUnits: 12.0, } } var current atomic.Value func init() { v := DefaultValues() current.Store(&v) } func Get() Values { p := current.Load().(*Values) return *p } // EffectiveNPCShopCosts returns potion and full-heal prices from runtime tuning (DB-merged JSON), // falling back to defaults when unset or non-positive. func EffectiveNPCShopCosts() (potionCost, healCost int64) { cfg := Get() potionCost = cfg.NPCCostPotion if potionCost <= 0 { potionCost = DefaultValues().NPCCostPotion } healCost = cfg.NPCCostHeal if healCost <= 0 { healCost = DefaultValues().NPCCostHeal } return potionCost, healCost } // EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning. func EffectiveQuestOffersPerNPC() int { n := Get().QuestOffersPerNPC if n <= 0 { return DefaultValues().QuestOffersPerNPC } return n } // EffectiveQuestOfferRefreshHours returns the rotation cadence (hours) for quest_giver offers. func EffectiveQuestOfferRefreshHours() int { n := Get().QuestOfferRefreshHours if n <= 0 { return DefaultValues().QuestOfferRefreshHours } return n } func effectiveRegenPerSecond(cfg float64, fallback float64) float64 { if cfg <= 0 { return fallback } return cfg } // EffectiveEnemyRegenDefault returns enemy regen rate (MaxHP per second) from runtime config with code fallback. func EffectiveEnemyRegenDefault() float64 { return effectiveRegenPerSecond(Get().EnemyRegenDefault, DefaultEnemyRegenDefault) } // EffectiveEnemyRegenSkeletonKing returns Skeleton King regen rate (MaxHP per second) from runtime config with code fallback. func EffectiveEnemyRegenSkeletonKing() float64 { return effectiveRegenPerSecond(Get().EnemyRegenSkeletonKing, DefaultEnemyRegenSkeletonKing) } // EffectiveEnemyRegenForestWarden returns Forest Warden regen rate from runtime config with code fallback. func EffectiveEnemyRegenForestWarden() float64 { return effectiveRegenPerSecond(Get().EnemyRegenForestWarden, DefaultEnemyRegenForestWarden) } // EffectiveEnemyRegenBattleLizard returns Battle Lizard regen rate from runtime config with code fallback. func EffectiveEnemyRegenBattleLizard() float64 { return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard) } func Set(v Values) { current.Store(&v) } type PayloadLoader interface { LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error) } func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) error { payload, err := loader.LoadRuntimeConfigPayload(ctx) if err != nil { if logger != nil { logger.Warn("runtime config reload failed", "error", err) } return err } next := DefaultValues() if len(payload) > 0 { if err := json.Unmarshal(payload, &next); err != nil { if logger != nil { logger.Warn("runtime config payload parse failed", "error", err) } return err } } Set(next) return nil }