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    pub config: &'a GameConfig,
27    pub lookups: &'a ContentLookups,
28}
29
30/// Signature of a bundle currency-step reward fn. Free `fn` (no captured
31/// state) so it is `Copy` and trivially stored in the registry.
32pub type RewardFn = fn(&RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>>;
33
34/// Build a `Vec<ESCurrencyUnit>` from a fixed `(uuid_str, amount)` list,
35/// preserving order. Shared with the test fixtures.
36pub(crate) fn fixed_currencies(entries: &[(&str, i64)]) -> anyhow::Result<Vec<ESCurrencyUnit>> {
37    entries
38        .iter()
39        .map(|(id, amount)| {
40            let currency_id = Uuid::parse_str(id)
41                .map_err(|err| anyhow::anyhow!("rewards: bad uuid {id:?}: {err}"))?;
42            Ok(ESCurrencyUnit {
43                currency_id,
44                amount: *amount,
45            })
46        })
47        .collect()
48}
49
50/// AFK reward accrual (the `AFK_Rewards` bundle step): currency rates per
51/// elapsed minute scaled by talents and the ads boost, plus weighted bonus
52/// draws.
53///
54/// Determinism contract: the caller seeds the RNG from
55/// `character.afk_reward_seed`; each bonus iteration draws exactly one
56/// `random_f64()` (see [`rand_weight_bonus`]). The accumulation map is a
57/// `BTreeMap` keyed by the currency id string, so output order is the sorted
58/// id order. Do not change the draw count, draw order, or output order.
59pub fn afk_rewards_step0(ctx: &RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>> {
60    use std::collections::BTreeMap;
61
62    let character = ctx
63        .character
64        .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: CharacterState not in scope"))?;
65    let now = ctx
66        .now
67        .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: Now not in scope"))?;
68    let last_claim_at = ctx
69        .last_claim_at
70        .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: LastClaimAt not in scope"))?;
71    let rng = ctx
72        .rng
73        .ok_or_else(|| anyhow::anyhow!("afk_rewards_step0: Random not in scope"))?;
74
75    // +10% per talent level on afk duration cap / efficiency.
76    let duration_talent_id = Uuid::from_u128(0x019d1c46_af25_741f_a514_9175dda443b2);
77    let efficiency_talent_id = Uuid::from_u128(0x019d1c46_fd2e_7b36_b7e3_51b7fcd8553c);
78    let duration_talent_level = character.talent_levels.0.get(&duration_talent_id).copied();
79    let efficiency_talent_level = character
80        .talent_levels
81        .0
82        .get(&efficiency_talent_id)
83        .copied();
84
85    let settings = &ctx.config.afk_rewards_settings;
86
87    let mut max_duration = settings.max_possible_time_sec as f64;
88    if let Some(level) = duration_talent_level {
89        max_duration *= 1.0 + 0.1 * level as f64;
90    }
91
92    let mut efficiency = 1.0_f64;
93    if let Some(level) = efficiency_talent_level {
94        efficiency *= 1.0 + 0.1 * level as f64;
95    }
96
97    // Idle income keys off the current chapter level (the genre-standard
98    // "idle income = f(stage reached)" hook), so pushing deeper always raises
99    // idle/sec. `current_chapter_level` is monotonic in production (only the
100    // chapter-clear path writes it, and only upward), so it already behaves as a
101    // high-water mark; a dedicated furthest field is only warranted once a
102    // feature can lower it (endless-tower reset / prestige).
103    let current_chapter_level = character.character.current_chapter_level;
104    let ads_multiplier = character.character.afk_boost_pending_stacks + 1;
105
106    // Highest configured level not above the player's chapter level.
107    let mut afk_levels: Vec<&_> = ctx.config.afk_rewards_levels.iter().collect();
108    afk_levels.sort_by_key(|l| std::cmp::Reverse(l.chapter_level));
109    let Some(level) = afk_levels
110        .into_iter()
111        .find(|l| l.chapter_level <= current_chapter_level)
112    else {
113        return Ok(vec![]);
114    };
115
116    let delta_secs = (now as i64).wrapping_sub(last_claim_at as i64);
117    let seconds = (delta_secs as f64).min(max_duration);
118
119    let minutes = seconds / 60.0;
120    let bonus_iterations =
121        (seconds / settings.bonus_calculation_rate_sec.get() as f64 * efficiency).floor() as i64;
122
123    let mut res: BTreeMap<String, i64> = BTreeMap::new();
124
125    for currency_rate in &level.currency_rates {
126        let per_min: f64 = currency_rate.rate_per_minute.get();
127        let amount = (per_min * minutes).floor() as i64 * ads_multiplier;
128        let key = currency_rate.currency_id.to_string();
129        *res.entry(key).or_insert(0) += amount;
130    }
131
132    if !level.bonus_weights.is_empty() {
133        for _ in 0..bonus_iterations {
134            if let Some(bonus) = rand_weight_bonus(rng, &level.bonus_weights)
135                && let AfkRewardBonusType::Currency(currency_id) = &bonus.bonus_type
136            {
137                let amount = bonus.count.get();
138                *res.entry(currency_id.to_string()).or_insert(0) += amount;
139            }
140        }
141    }
142
143    res.into_iter()
144        .map(|(k, amount)| {
145            let currency_id = Uuid::parse_str(&k)
146                .map_err(|err| anyhow::anyhow!("afk_rewards_step0: bad uuid {k:?}: {err}"))?;
147            Ok(ESCurrencyUnit {
148                currency_id,
149                amount,
150            })
151        })
152        .collect()
153}
154
155/// Weighted pick over `bonus_weights`, drawing exactly one `random_f64()`.
156/// Algorithm must stay bit-for-bit stable (determinism contract): sum the
157/// weights (no draw), `weight = random_f64() * sum` (single draw), then walk
158/// the slice subtracting weights, returning the first element whose running
159/// threshold is reached; falls back to the last element. `sum <= 0` → `None`.
160fn rand_weight_bonus<'a>(
161    rng: &event_system::script::random::GameRng,
162    arr: &'a [configs::afk_rewards::AfkRewardBonusWeight],
163) -> Option<&'a configs::afk_rewards::AfkRewardBonusWeight> {
164    let sum_weight: f64 = arr.iter().map(|e| e.weight.get()).sum();
165    if sum_weight <= 0.0 {
166        return None;
167    }
168    let mut weight = rng.random_f64() * sum_weight;
169    for elem in arr {
170        weight -= elem.weight.get();
171        if weight <= 0.0 {
172            return Some(elem);
173        }
174    }
175    arr.last()
176}
177
178/// Test AFK accrual step (`afk_rewards_settings.bundle_id` fixture bundle):
179/// elapsed `> 8s` → 228, otherwise → 111, of the soft currency.
180pub fn test_afk_currency_step0(ctx: &RewardCtx) -> anyhow::Result<Vec<ESCurrencyUnit>> {
181    let now = ctx
182        .now
183        .ok_or_else(|| anyhow::anyhow!("test_afk_currency_step0: Now not in scope"))?;
184    let last_claim_at = ctx
185        .last_claim_at
186        .ok_or_else(|| anyhow::anyhow!("test_afk_currency_step0: LastClaimAt not in scope"))?;
187    let amount = if now.saturating_sub(last_claim_at) > 8 {
188        228
189    } else {
190        111
191    };
192    fixed_currencies(&[("b59b33a2-4d19-4e2c-9cea-e03ea15882a0", amount)])
193}
194
195/// Register this category's behaviors.
196pub fn register(registry: &mut BehaviorRegistry) {
197    registry.register_currencies(
198        BehaviorMeta {
199            name: "afk_rewards_step0".to_string(),
200            category: BehaviorKind::Currencies,
201            title: "AFK-начисление валют".to_string(),
202            description: "Начисление валют за оффлайн-время: ставки в минуту с учётом \
203                талантов и ads-буста, плюс взвешенные бонусные дропы \
204                (детерминированный RNG от afk_reward_seed)."
205                .to_string(),
206        },
207        afk_rewards_step0,
208    );
209    registry.register_currencies(
210        BehaviorMeta {
211            name: "test_afk_currency_step0".to_string(),
212            category: BehaviorKind::Currencies,
213            title: "Тест: AFK-награда (111 / 228)".to_string(),
214            description: "Тестовый AFK-шаг: Now - LastClaimAt > 8 → 228, иначе 111 мягкой валюты."
215                .to_string(),
216        },
217        test_afk_currency_step0,
218    );
219}