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
17pub 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
49fn 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 BundleStepType::Artifact => process_artifact(raw_item, game_config),
161 }
162}
163
164fn 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
190fn process_currency(
193 raw_item: &BundleRawStep,
194 character_state: &CharacterState,
195 behaviors: &BehaviorRegistry,
196 game_config: &GameConfig,
197) -> BundleElement {
198 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
233fn 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
278fn 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
288fn 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 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
372fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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}