overlord_event_system/
bundles.rs

1use configs::game_config::GameConfig;
2use essences::{
3    bundles::{BundleAbility, BundleElement, BundleRaw, BundleRawStep, BundleStepType},
4    character_state::CharacterState,
5    currency::from_es_currencies,
6    items::Item,
7};
8
9use event_system::script::random::GameRng;
10
11use crate::{
12    BehaviorRegistry, cases::try_finalize_item, gacha::item_case::generate_item_from_template,
13    game_config_helpers::GameConfigLookup,
14};
15use rand::SeedableRng;
16
17/// Drops everything a stored bundle references that no longer exists in the
18/// live config, and returns `None` when nothing claimable is left.
19///
20/// Config bundles are looked up by id and re-read from `GameConfig` on every
21/// load, so they cannot go stale. Operator-composed gifts have no config entry
22/// — they are snapshots persisted with the mail and with the character_bundles
23/// row — so an item template, ability or currency removed from the config
24/// after the gift was sent would otherwise be granted as a dangling id. Item
25/// and ability steps already skip unknown ids when they are turned into
26/// elements; currencies do not, and an emptied step would still produce an
27/// empty reward popup. Both are handled here.
28pub fn sanitize_bundle_for_config(
29    bundle: &BundleRaw,
30    game_config: &GameConfig,
31) -> Option<BundleRaw> {
32    let steps: Vec<BundleRawStep> = bundle
33        .steps
34        .iter()
35        .filter_map(|step| sanitize_step_for_config(step, game_config))
36        .collect();
37
38    if steps.is_empty() {
39        return None;
40    }
41
42    Some(BundleRaw {
43        id: bundle.id,
44        steps,
45        claim_mode: bundle.claim_mode,
46    })
47}
48
49/// One step of [`sanitize_bundle_for_config`]. `None` means the step lost all
50/// of its content and must be dropped from the bundle.
51fn sanitize_step_for_config(
52    step: &BundleRawStep,
53    game_config: &GameConfig,
54) -> Option<BundleRawStep> {
55    let mut step = step.clone();
56
57    match step.item_type {
58        BundleStepType::Currency => {
59            let known = |unit: &essences::currency::CurrencyUnit| {
60                game_config
61                    .currencies
62                    .iter()
63                    .any(|currency| currency.id == unit.currency_id)
64            };
65
66            step.currencies.retain(known);
67            if let Some(branch) = &mut step.currency_branch {
68                for case in &mut branch.branches {
69                    case.currencies.retain(known);
70                }
71                branch.branches.retain(|case| !case.currencies.is_empty());
72                branch.default.retain(known);
73            }
74
75            let has_branch = step
76                .currency_branch
77                .as_ref()
78                .is_some_and(|branch| !branch.branches.is_empty() || !branch.default.is_empty());
79
80            if step.currencies.is_empty() && !has_branch && step.behavior.is_none() {
81                return None;
82            }
83        }
84        BundleStepType::Ability => {
85            step.shards
86                .retain(|shard| game_config.ability_template(shard.ability_id).is_some());
87            if step.shards.is_empty() {
88                return None;
89            }
90        }
91        BundleStepType::Item => {
92            step.item_template_ids.retain(|&item_id| {
93                game_config
94                    .item_template(item_id)
95                    .is_some_and(|template| game_config.item_rarity(template.rarity_id).is_some())
96            });
97            if step.item_template_ids.is_empty() {
98                return None;
99            }
100        }
101        BundleStepType::Artifact => {
102            step.artifact_template_ids.retain(|artifact_id| {
103                game_config
104                    .artifacts
105                    .iter()
106                    .any(|template| template.id == *artifact_id)
107            });
108            if step.artifact_template_ids.is_empty() {
109                return None;
110            }
111        }
112    }
113
114    Some(step)
115}
116
117pub fn bundle_raw_step_to_element(
118    raw_item: &BundleRawStep,
119    character_state: &CharacterState,
120    behaviors: &BehaviorRegistry,
121    game_config: &GameConfig,
122) -> BundleElement {
123    match raw_item.item_type {
124        BundleStepType::Currency => {
125            process_currency(raw_item, character_state, behaviors, game_config)
126        }
127        BundleStepType::Ability => {
128            process_ability(raw_item, character_state, behaviors, game_config)
129        }
130        BundleStepType::Item => process_item(raw_item, character_state, behaviors, game_config),
131        BundleStepType::Artifact => process_artifact(raw_item, game_config),
132    }
133}
134
135pub fn bundle_raw_afk_step_to_element(
136    raw_item: &BundleRawStep,
137    character_state: &CharacterState,
138    now: chrono::DateTime<chrono::Utc>,
139    apply_afk_boost: bool,
140    behaviors: &BehaviorRegistry,
141    game_config: &GameConfig,
142) -> BundleElement {
143    match raw_item.item_type {
144        BundleStepType::Currency => process_afk_currency(
145            raw_item,
146            character_state,
147            now,
148            apply_afk_boost,
149            behaviors,
150            game_config,
151        ),
152        BundleStepType::Ability => {
153            process_afk_ability(raw_item, character_state, now, behaviors, game_config)
154        }
155        BundleStepType::Item => {
156            process_afk_item(raw_item, character_state, now, behaviors, game_config)
157        }
158        // No afk-specific variant: an artifact step is a fixed id list with no
159        // roll, no rarity and no per-claim RNG, so the two paths cannot differ.
160        BundleStepType::Artifact => process_artifact(raw_item, game_config),
161    }
162}
163
164/// Bundle `artifact` step: the typed id list from the config, filtered to the
165/// catalog.
166///
167/// Unknown ids are dropped with a log rather than granted: a dangling template
168/// would reach `grant_artifact` and create a collection row pointing at nothing,
169/// which every artifact read then has to tolerate forever.
170fn process_artifact(raw_item: &BundleRawStep, game_config: &GameConfig) -> BundleElement {
171    let artifacts = raw_item
172        .artifact_template_ids
173        .iter()
174        .filter(|artifact_id| {
175            let known = game_config
176                .artifacts
177                .iter()
178                .any(|template| template.id == **artifact_id);
179            if !known {
180                tracing::error!("Bundle step references unknown artifact {artifact_id}");
181            }
182            known
183        })
184        .copied()
185        .collect();
186
187    BundleElement::Artifacts(artifacts)
188}
189
190/// Bundle `currencies` step: fixed list, custom-values branch, or the
191/// config-named native fn, in that order. Empty result if none is set.
192fn process_currency(
193    raw_item: &BundleRawStep,
194    character_state: &CharacterState,
195    behaviors: &BehaviorRegistry,
196    game_config: &GameConfig,
197) -> BundleElement {
198    // Fixed and branch rewards live in the config (single source of truth for
199    // server + client); only non-fixed steps (afk accrual) fall back to a
200    // native `currencies` fn.
201    if !raw_item.currencies.is_empty() {
202        return BundleElement::Currencies(raw_item.currencies.clone());
203    }
204    if let Some(branch) = &raw_item.currency_branch {
205        return BundleElement::Currencies(branch.evaluate(character_state).clone());
206    }
207
208    let es_currencies = raw_item
209        .behavior
210        .as_deref()
211        .and_then(|name| behaviors.currencies_fn(name))
212        .map(|f| {
213            f(&crate::behaviors::rewards::RewardCtx {
214                character: Some(character_state),
215                last_claim_at: None,
216                now: None,
217                rng: None,
218                apply_afk_boost: false,
219                config: game_config,
220                lookups: behaviors.lookups(),
221            })
222        })
223        .transpose()
224        .unwrap_or_else(|e| {
225            tracing::error!("Failed to run native currencies bundle step: {e}");
226            None
227        })
228        .unwrap_or_default();
229
230    BundleElement::Currencies(from_es_currencies(&es_currencies))
231}
232
233/// Native afk `currencies` step. The afk port draws against a `StdRng` seeded
234/// from `afk_reward_seed` (via `util::rand_weight`) and reads the
235/// `LastClaimAt`/`Now` constants.
236fn process_afk_currency(
237    raw_item: &BundleRawStep,
238    character_state: &CharacterState,
239    now: chrono::DateTime<chrono::Utc>,
240    apply_afk_boost: bool,
241    behaviors: &BehaviorRegistry,
242    game_config: &GameConfig,
243) -> BundleElement {
244    let last_claim_at = character_state
245        .character
246        .last_afk_reward_claimed_at
247        .timestamp()
248        .max(0) as u64;
249    let now_secs = now.timestamp().max(0) as u64;
250    let afk_seed = character_state.character.afk_reward_seed;
251    let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(afk_seed));
252
253    let es_currencies = raw_item
254        .behavior
255        .as_deref()
256        .and_then(|name| behaviors.currencies_fn(name))
257        .map(|f| {
258            f(&crate::behaviors::rewards::RewardCtx {
259                character: Some(character_state),
260                last_claim_at: Some(last_claim_at),
261                now: Some(now_secs),
262                rng: Some(&rng),
263                apply_afk_boost,
264                config: game_config,
265                lookups: behaviors.lookups(),
266            })
267        })
268        .transpose()
269        .unwrap_or_else(|e| {
270            tracing::error!("Failed to run native afk currencies bundle step: {e}");
271            None
272        })
273        .unwrap_or_default();
274
275    BundleElement::Currencies(from_es_currencies(&es_currencies))
276}
277
278/// Bundle `ability_shards` step: the typed `shards` list from the config.
279fn process_ability(
280    raw_item: &BundleRawStep,
281    _character_state: &CharacterState,
282    _script_runner: &BehaviorRegistry,
283    game_config: &GameConfig,
284) -> BundleElement {
285    BundleElement::Abilities(build_abilities(&raw_item.shards, game_config))
286}
287
288/// Afk `ability_shards` step — same typed `shards` list as the regular path.
289fn process_afk_ability(
290    raw_item: &BundleRawStep,
291    _character_state: &CharacterState,
292    _now: chrono::DateTime<chrono::Utc>,
293    _script_runner: &BehaviorRegistry,
294    game_config: &GameConfig,
295) -> BundleElement {
296    BundleElement::Abilities(build_abilities(&raw_item.shards, game_config))
297}
298
299fn build_abilities(
300    shards: &[essences::bundles::BundleShardAmount],
301    game_config: &GameConfig,
302) -> Vec<BundleAbility> {
303    shards
304        .iter()
305        .filter_map(|shard| {
306            let Some(template) = game_config.ability_template(shard.ability_id).cloned() else {
307                tracing::error!("Failed to get ability with ability_id={}", shard.ability_id);
308                return None;
309            };
310
311            Some(BundleAbility {
312                template,
313                shards_amount: shard.amount,
314            })
315        })
316        .collect()
317}
318
319fn process_item(
320    raw_item: &BundleRawStep,
321    character_state: &CharacterState,
322    behaviors: &BehaviorRegistry,
323    game_config: &GameConfig,
324) -> BundleElement {
325    let items: Vec<Item> = raw_item
326        .item_template_ids
327        .iter()
328        .filter_map(|&item_id| {
329            let Some(template) = game_config.item_template(item_id) else {
330                tracing::error!("Failed to get item template with item_id={}", item_id);
331                return None;
332            };
333
334            let Some(rarity) = game_config.item_rarity(template.rarity_id).cloned() else {
335                tracing::error!("Failed to get item rarity with id={}", template.rarity_id);
336                return None;
337            };
338
339            Some(generate_item_from_template(
340                template,
341                rarity,
342                character_state.character.character_level,
343                // Not a chest roll: keep the template's own count.
344                template.attributes_settings.optional_attributes_count,
345                game_config,
346                &mut rand::rngs::StdRng::try_from_rng(&mut rand::rngs::SysRng)
347                    .expect("OS entropy unavailable"),
348            ))
349        })
350        .collect();
351
352    let expires_at = item_ttl_expires_at(raw_item, ::time::utc_now());
353    let finalized_items = items
354        .into_iter()
355        .filter_map(|mut item| {
356            match try_finalize_item(&mut item, game_config, behaviors, &GameRng::from_entropy()) {
357                Ok(()) => {
358                    item.expires_at = expires_at;
359                    Some(item)
360                }
361                Err(e) => {
362                    tracing::error!("Failed to finalize item: {}", e);
363                    None
364                }
365            }
366        })
367        .collect();
368
369    BundleElement::Items(finalized_items)
370}
371
372/// Момент истечения временных предметов шага: `now + item_ttl_seconds`.
373/// `None`, если у шага нет TTL (предметы постоянные).
374fn item_ttl_expires_at(
375    raw_item: &BundleRawStep,
376    now: chrono::DateTime<chrono::Utc>,
377) -> Option<chrono::DateTime<chrono::Utc>> {
378    raw_item
379        .item_ttl_seconds
380        .map(|secs| now + chrono::Duration::seconds(secs))
381}
382
383fn process_afk_item(
384    raw_item: &BundleRawStep,
385    character_state: &CharacterState,
386    now: chrono::DateTime<chrono::Utc>,
387    behaviors: &BehaviorRegistry,
388    game_config: &GameConfig,
389) -> BundleElement {
390    let items: Vec<Item> = raw_item
391        .item_template_ids
392        .iter()
393        .filter_map(|&item_id| {
394            let Some(template) = game_config.item_template(item_id) else {
395                tracing::error!("Failed to get item template with item_id={}", item_id);
396                return None;
397            };
398
399            let Some(rarity) = game_config.item_rarity(template.rarity_id).cloned() else {
400                tracing::error!("Failed to get item rarity with id={}", template.rarity_id);
401                return None;
402            };
403
404            Some(generate_item_from_template(
405                template,
406                rarity,
407                character_state.character.character_level,
408                // Not a chest roll: keep the template's own count.
409                template.attributes_settings.optional_attributes_count,
410                game_config,
411                &mut rand::rngs::StdRng::seed_from_u64(character_state.character.afk_reward_seed),
412            ))
413        })
414        .collect();
415
416    let expires_at = item_ttl_expires_at(raw_item, now);
417    let finalized_items = items
418        .into_iter()
419        .filter_map(|mut item| {
420            match try_finalize_item(&mut item, game_config, behaviors, &GameRng::from_entropy()) {
421                Ok(()) => {
422                    item.expires_at = expires_at;
423                    Some(item)
424                }
425                Err(e) => {
426                    tracing::error!("Failed to finalize item: {}", e);
427                    None
428                }
429            }
430        })
431        .collect();
432
433    BundleElement::Items(finalized_items)
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use essences::bundles::{BundleClaimMode, BundleStepType};
440
441    fn config() -> GameConfig {
442        configs::tests_game_config::generate_game_config_for_tests()
443    }
444
445    fn artifact_step(ids: Vec<uuid::Uuid>) -> BundleRawStep {
446        BundleRawStep {
447            item_type: BundleStepType::Artifact,
448            currencies: vec![],
449            currency_branch: None,
450            behavior: None,
451            shards: vec![],
452            item_template_ids: vec![],
453            artifact_template_ids: ids,
454            item_ttl_seconds: None,
455            has_pop_up: true,
456        }
457    }
458
459    fn behaviors(config: &GameConfig) -> BehaviorRegistry {
460        BehaviorRegistry::new(config)
461    }
462
463    /// Sanitize keeps the ids the catalog still has. An operator gift or a
464    /// stored mail is a SNAPSHOT — the artifact it names can be deleted from the
465    /// config after it is sent, and a dangling id would reach `grant_artifact`
466    /// and create a collection row pointing at nothing.
467    #[test]
468    fn sanitize_retains_only_catalog_artifacts() {
469        let config = config();
470        let known = config.artifacts[0].id;
471        let unknown = uuid::Uuid::from_u128(0xDEAD);
472
473        let step = sanitize_step_for_config(&artifact_step(vec![known, unknown]), &config)
474            .expect("a step keeping one known artifact must survive");
475        assert_eq!(step.artifact_template_ids, vec![known]);
476    }
477
478    /// ...and a step that loses every id is dropped whole, so the claim does not
479    /// produce an empty reward popup.
480    #[test]
481    fn sanitize_drops_a_step_with_no_known_artifact() {
482        let config = config();
483        let unknown = uuid::Uuid::from_u128(0xDEAD);
484        assert!(sanitize_step_for_config(&artifact_step(vec![unknown]), &config).is_none());
485        assert!(sanitize_step_for_config(&artifact_step(vec![]), &config).is_none());
486    }
487
488    /// A bundle whose only step is an all-unknown artifact step has nothing left
489    /// to claim, so the whole bundle goes.
490    #[test]
491    fn sanitize_drops_a_bundle_left_with_nothing() {
492        let config = config();
493        let bundle = BundleRaw {
494            id: uuid::Uuid::from_u128(1),
495            steps: vec![artifact_step(vec![uuid::Uuid::from_u128(0xDEAD)])],
496            claim_mode: BundleClaimMode::AllAtOnce,
497        };
498        assert!(sanitize_bundle_for_config(&bundle, &config).is_none());
499    }
500
501    /// The element conversion carries the ids through untouched.
502    #[test]
503    fn an_artifact_step_becomes_an_artifacts_element() {
504        let config = config();
505        let behaviors = behaviors(&config);
506        let character = CharacterState::default();
507        let ids: Vec<_> = config.artifacts.iter().map(|a| a.id).collect();
508        assert!(
509            ids.len() > 1,
510            "the fixture must ship more than one artifact"
511        );
512
513        let element = bundle_raw_step_to_element(
514            &artifact_step(ids.clone()),
515            &character,
516            &behaviors,
517            &config,
518        );
519        assert_eq!(element, BundleElement::Artifacts(ids));
520    }
521
522    /// The afk path must agree with the regular one: an artifact step has no
523    /// roll, no rarity and no per-claim RNG, so the two cannot legitimately
524    /// differ — and a divergence would mean an afk-claimed artifact behaved
525    /// differently from a quest-claimed one.
526    #[test]
527    fn the_afk_path_converts_an_artifact_step_identically() {
528        let config = config();
529        let behaviors = behaviors(&config);
530        let character = CharacterState::default();
531        let step = artifact_step(vec![config.artifacts[0].id]);
532
533        let regular = bundle_raw_step_to_element(&step, &character, &behaviors, &config);
534        let afk = bundle_raw_afk_step_to_element(
535            &step,
536            &character,
537            ::time::utc_now(),
538            true,
539            &behaviors,
540            &config,
541        );
542        assert_eq!(regular, afk);
543    }
544
545    /// An unknown id is dropped at conversion time too, not just by sanitize —
546    /// config bundles are re-read from `GameConfig` on every load and never pass
547    /// through sanitize at all.
548    #[test]
549    fn conversion_drops_an_unknown_artifact_id() {
550        let config = config();
551        let behaviors = behaviors(&config);
552        let character = CharacterState::default();
553        let known = config.artifacts[0].id;
554
555        let element = bundle_raw_step_to_element(
556            &artifact_step(vec![known, uuid::Uuid::from_u128(0xDEAD)]),
557            &character,
558            &behaviors,
559            &config,
560        );
561        assert_eq!(element, BundleElement::Artifacts(vec![known]));
562    }
563}