overlord_event_system/behaviors/
rewards.rs

1//! Reward behaviors for bundle currency steps. Fixed reward lists live in the
2//! config (`BundleRawStep::currencies`, `CurrencyBranchStep`,
3//! `QuestsProgressionPointSettings::reward`); the only computed reward is the
4//! AFK accrual.
5
6use 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
15/// Inputs available to a bundle currency-step reward fn. The afk-claim call
16/// site provides the claim window and a seeded RNG; the plain claim site
17/// leaves them `None`.
18pub struct RewardCtx<'a> {
19    pub character: Option<&'a CharacterState>,
20    /// Last-claim unix seconds (clamped `>= 0`) — afk accrual only.
21    pub last_claim_at: Option<u64>,
22    /// Current unix seconds (clamped `>= 0`) — afk accrual only.
23    pub now: Option<u64>,
24    /// Seeded RNG — afk accrual only (seeded from `character.afk_reward_seed`).
25    pub rng: Option<&'a event_system::script::random::GameRng>,
26    /// Whether a pending AFK ad charge may scale this accrual. Only the
27    /// ordinary claim path (and its preview) sets this: the charge is spent by
28    /// `ClaimAfkReward` alone and never amplifies an instant ad/Gem grant.
29    pub apply_afk_boost: bool,
30    pub config: &'a GameConfig,
31    pub lookups: &'a ContentLookups,
32}
33
34/// Signature of a bundle currency-step reward fn. Free `fn` (no captured
35/// state) so it is `Copy` and trivially stored in the registry.
36pub type RewardFn = fn(&RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>>;
37
38/// Build a `Vec<ESCurrencyUnit>` from a fixed `(uuid_str, amount)` list,
39/// preserving order. Shared with the test fixtures.
40pub(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
54/// AFK reward accrual (the `AFK_Rewards` bundle step): currency rates per
55/// elapsed minute scaled by talents and the ads boost, plus weighted bonus
56/// draws.
57///
58/// Determinism contract: the caller seeds the RNG from
59/// `character.afk_reward_seed`; each bonus iteration draws exactly one
60/// `random_f64()` (see [`rand_weight_bonus`]). The accumulation map is a
61/// `BTreeMap` keyed by the currency id string, so output order is the sorted
62/// id order. Do not change the draw count, draw order, or output order.
63pub 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    // +10% per talent level on afk duration cap / efficiency.
80    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    // Idle income keys off the current chapter level (the genre-standard
102    // "idle income = f(stage reached)" hook), so pushing deeper always raises
103    // idle/sec. `current_chapter_level` is monotonic in production (only the
104    // chapter-clear path writes it, and only upward), so it already behaves as a
105    // high-water mark; a dedicated furthest field is only warranted once a
106    // feature can lower it (endless-tower reset / prestige).
107    let current_chapter_level = character.character.current_chapter_level;
108    // One pending charge, one authored multiplier: the charge never stacks and
109    // never applies outside the ordinary claim (BAL-016).
110    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    // Highest configured level not above the player's chapter level.
118    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
166/// Weighted pick over `bonus_weights`, drawing exactly one `random_f64()`.
167/// Algorithm must stay bit-for-bit stable (determinism contract): sum the
168/// weights (no draw), `weight = random_f64() * sum` (single draw), then walk
169/// the slice subtracting weights, returning the first element whose running
170/// threshold is reached; falls back to the last element. `sum <= 0` → `None`.
171fn 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
189/// Test AFK accrual step (`afk_rewards_settings.bundle_id` fixture bundle):
190/// elapsed `> 8s` → 228, otherwise → 111, of the soft currency.
191pub 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
206/// BAL-008/BAL-036: the AD Bird Gold pack — a share of the NEXT chest
207/// upgrade's price `C_(L+1)` at the player's current Chest Level `L`.
208///
209/// `share(L) = 0.6/(13L−10)` for `1≤L≤10` (a smooth ≈1/x fit from 20% at L1 to
210/// 0.5% at L10), then linear `0.005 − 0.0004×(L−10)` down to 0.1% at L20, flat
211/// `0.001` past it. `raw(L) = round(C_(L+1) × share(L))`, and the authored
212/// reward is the monotonic clamp `BirdGold(L) = max(BirdGold(L−1), raw(L))` —
213/// the declining share never produces an absolute dip on a Chest Level up.
214pub 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    // The clamp is stateless: recompute the running max from L1 up.
247    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    // Past the last priced chest the clamp keeps the last computable pack; a
253    // zero here means no level ever had a priced next chest — a config gap.
254    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
260/// Register this category's behaviors.
261pub 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}