overlord_event_system/behaviors/
rewards.rs1use configs::afk_rewards::AfkRewardBonusType;
7use configs::game_config::GameConfig;
8use essences::character_state::CharacterState;
9use event_system::script::types::ESCurrencyUnit;
10use uuid::Uuid;
11
12use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
13use crate::mechanics::content_lookups::ContentLookups;
14
15pub struct RewardCtx<'a> {
19 pub character: Option<&'a CharacterState>,
20 pub last_claim_at: Option<u64>,
22 pub now: Option<u64>,
24 pub rng: Option<&'a event_system::script::random::GameRng>,
26 pub apply_afk_boost: bool,
30 pub config: &'a GameConfig,
31 pub lookups: &'a ContentLookups,
32}
33
34pub type RewardFn = fn(&RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>>;
37
38pub(crate) fn fixed_currencies(entries: &[(&str, i64)]) -> anyhow::Result<Vec<ESCurrencyUnit>> {
41 entries
42 .iter()
43 .map(|(id, amount)| {
44 let currency_id = Uuid::parse_str(id)
45 .map_err(|err| anyhow::anyhow!("rewards: bad uuid {id:?}: {err}"))?;
46 Ok(ESCurrencyUnit {
47 currency_id,
48 amount: *amount,
49 })
50 })
51 .collect()
52}
53
54pub fn afk_rewards_step0(ctx: &RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>> {
64 use std::collections::BTreeMap;
65
66 let character = ctx
67 .character
68 .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: CharacterState not in scope"))?;
69 let now = ctx
70 .now
71 .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: Now not in scope"))?;
72 let last_claim_at = ctx
73 .last_claim_at
74 .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: LastClaimAt not in scope"))?;
75 let rng = ctx
76 .rng
77 .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: Random not in scope"))?;
78
79 let duration_talent_id = Uuid::from_u128(0x019d1c46_af25_741f_a514_9175dda443b2);
81 let efficiency_talent_id = Uuid::from_u128(0x019d1c46_fd2e_7b36_b7e3_51b7fcd8553c);
82 let duration_talent_level = character.talent_levels.0.get(&duration_talent_id).copied();
83 let efficiency_talent_level = character
84 .talent_levels
85 .0
86 .get(&efficiency_talent_id)
87 .copied();
88
89 let settings = &ctx.config.afk_rewards_settings;
90
91 let mut max_duration = settings.max_possible_time_sec as f64;
92 if let Some(level) = duration_talent_level {
93 max_duration *= 1.0 + 0.1 * level as f64;
94 }
95
96 let mut efficiency = 1.0_f64;
97 if let Some(level) = efficiency_talent_level {
98 efficiency *= 1.0 + 0.1 * level as f64;
99 }
100
101 let current_chapter_level = character.character.current_chapter_level;
108 let ads_multiplier = if ctx.apply_afk_boost && character.character.afk_boost_pending_stacks > 0
111 {
112 ctx.config.ads_settings.afk_boost_multiplier
113 } else {
114 1.0
115 };
116
117 let mut afk_levels: Vec<&_> = ctx.config.afk_rewards_levels.iter().collect();
119 afk_levels.sort_by_key(|l| std::cmp::Reverse(l.chapter_level));
120 let Some(level) = afk_levels
121 .into_iter()
122 .find(|l| l.chapter_level <= current_chapter_level)
123 else {
124 return Ok(vec![]);
125 };
126
127 let delta_secs = (now as i64).wrapping_sub(last_claim_at as i64);
128 let seconds = (delta_secs as f64).min(max_duration);
129
130 let minutes = seconds / 60.0;
131 let bonus_iterations =
132 (seconds / settings.bonus_calculation_rate_sec.get() as f64 * efficiency).floor() as i64;
133
134 let mut res: BTreeMap<String, i64> = BTreeMap::new();
135
136 for currency_rate in &level.currency_rates {
137 let per_min: f64 = currency_rate.rate_per_minute.get();
138 let amount = ((per_min * minutes).floor() * ads_multiplier).floor() as i64;
139 let key = currency_rate.currency_id.to_string();
140 *res.entry(key).or_insert(0) += amount;
141 }
142
143 if !level.bonus_weights.is_empty() {
144 for _ in 0..bonus_iterations {
145 if let Some(bonus) = rand_weight_bonus(rng, &level.bonus_weights)
146 && let AfkRewardBonusType::Currency(currency_id) = &bonus.bonus_type
147 {
148 let amount = bonus.count.get();
149 *res.entry(currency_id.to_string()).or_insert(0) += amount;
150 }
151 }
152 }
153
154 res.into_iter()
155 .map(|(k, amount)| {
156 let currency_id = Uuid::parse_str(&k)
157 .map_err(|err| anyhow::anyhow!("afk_rewards_step0: bad uuid {k:?}: {err}"))?;
158 Ok(ESCurrencyUnit {
159 currency_id,
160 amount,
161 })
162 })
163 .collect()
164}
165
166fn rand_weight_bonus<'a>(
172 rng: &event_system::script::random::GameRng,
173 arr: &'a [configs::afk_rewards::AfkRewardBonusWeight],
174) -> Option<&'a configs::afk_rewards::AfkRewardBonusWeight> {
175 let sum_weight: f64 = arr.iter().map(|e| e.weight.get()).sum();
176 if sum_weight <= 0.0 {
177 return None;
178 }
179 let mut weight = rng.random_f64() * sum_weight;
180 for elem in arr {
181 weight -= elem.weight.get();
182 if weight <= 0.0 {
183 return Some(elem);
184 }
185 }
186 arr.last()
187}
188
189pub fn test_afk_currency_step0(ctx: &RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>> {
192 let now = ctx
193 .now
194 .ok_or_else(|| anyhow::anyhow!("test_afk_currency_step0: Now not in scope"))?;
195 let last_claim_at = ctx
196 .last_claim_at
197 .ok_or_else(|| anyhow::anyhow!("test_afk_currency_step0: LastClaimAt not in scope"))?;
198 let amount = if now.saturating_sub(last_claim_at) > 8 {
199 228
200 } else {
201 111
202 };
203 fixed_currencies(&[("b59b33a2-4d19-4e2c-9cea-e03ea15882a0", amount)])
204}
205
206pub fn bird_gold_pack(ctx: &RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>> {
215 const GOLD: &str = "0194d64e-2386-7020-8b01-d6b3d5424506";
216 let character = ctx
217 .character
218 .ok_or_else(|| anyhow::anyhow!("bird_gold_pack: CharacterState not in scope"))?;
219 let gold_id = Uuid::parse_str(GOLD).expect("static uuid");
220
221 let share = |l: i64| -> f64 {
222 if l <= 10 {
223 0.6 / (13 * l - 10) as f64
224 } else if l <= 20 {
225 0.005 - 0.0004 * (l - 10) as f64
226 } else {
227 0.001
228 }
229 };
230 let next_cost = |l: i64| -> i64 {
231 ctx.config
232 .item_cases_settings
233 .iter()
234 .find(|case| case.level == l + 1)
235 .map(|case| {
236 case.upgrade_cost
237 .iter()
238 .filter(|unit| unit.currency_id == gold_id)
239 .map(|unit| unit.amount)
240 .sum()
241 })
242 .unwrap_or(0)
243 };
244
245 let level = character.character.item_case_level.max(1);
246 let mut amount = 0i64;
248 for l in 1..=level {
249 let raw = (next_cost(l) as f64 * share(l)).round() as i64;
250 amount = amount.max(raw);
251 }
252 if amount <= 0 {
255 anyhow::bail!("bird_gold_pack: no priced next chest at any level up to {level}");
256 }
257 fixed_currencies(&[(GOLD, amount)])
258}
259
260pub fn register(registry: &mut BehaviorRegistry) {
262 registry.register_currencies(
263 BehaviorMeta {
264 name: "bird_gold_pack".to_string(),
265 category: BehaviorKind::Currencies,
266 title: "Bird: Gold-пакет от цены следующего сундука".to_string(),
267 description: "BAL-008/BAL-036: Gold = доля цены следующего апгрейда сундука \
268 C_(L+1); share 20%→0.5% (L1..10), линейно до 0.1% к L20, далее 0.1%; \
269 монотонный clamp по уровням."
270 .to_string(),
271 },
272 bird_gold_pack,
273 );
274 registry.register_currencies(
275 BehaviorMeta {
276 name: "afk_rewards_step0".to_string(),
277 category: BehaviorKind::Currencies,
278 title: "AFK-начисление валют".to_string(),
279 description: "Начисление валют за оффлайн-время: ставки в минуту с учётом \
280 талантов и ads-буста, плюс взвешенные бонусные дропы \
281 (детерминированный RNG от afk_reward_seed)."
282 .to_string(),
283 },
284 afk_rewards_step0,
285 );
286 registry.register_currencies(
287 BehaviorMeta {
288 name: "test_afk_currency_step0".to_string(),
289 category: BehaviorKind::Currencies,
290 title: "Тест: AFK-награда (111 / 228)".to_string(),
291 description: "Тестовый AFK-шаг: Now - LastClaimAt > 8 → 228, иначе 111 мягкой валюты."
292 .to_string(),
293 },
294 test_afk_currency_step0,
295 );
296}