1use configs::game_config::GameConfig;
9use essences::character_state::CharacterState;
10use event_system::script::random::GameRng;
11use uuid::Uuid;
12
13use crate::event::*;
14use crate::game_config_helpers::GameConfigLookup;
15use crate::mechanics::content_lookups::ContentLookups;
16
17const LEVEL_MILESTONE_FREQUENCY: i64 = 5;
23const LEVEL_MILESTONE_QUANTITY: i64 = 18;
24const STAGE_MILESTONE_FREQUENCY: i64 = 5;
25const STAGE_MILESTONE_QUANTITY: i64 = 19;
26
27const LOOP_POOL: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
36
37const GEMS_CURRENCY: u128 = 0x0194d64e_2162_76d3_8449_3e850f6e39e9;
40const SC_CURRENCY: u128 = 0x0195964c_61d6_7340_9748_32a3a4b1cd57;
41const BEAST_TOKEN_CURRENCY: u128 = 0x019cdc54_eae4_73c2_840b_e78dfe947141;
42
43const BOSS_WINNABLE_RATIO: f64 = 0.2;
48
49const GOLD_DUNGEON_ID: &str = "019aee96-7303-7d3e-a382-d7776687d24f";
50const COOKIE_DUNGEON_ID: &str = "019a9206-800b-781e-8998-38cdc6e9826e";
51const BLUEPRINT_DUNGEON_ID: &str = "019d2eca-9508-71c9-abb3-a6fc17474502";
52
53fn set_custom_value(key: &str, value: i64) -> OverlordEvent {
55 OverlordEvent::SetCustomValue {
56 key: key.to_string(),
57 value,
58 }
59}
60
61fn cv_i64(character_state: &CharacterState, key: &str) -> i64 {
62 cv_opt(character_state, key).unwrap_or(0)
63}
64
65fn cv_opt(character_state: &CharacterState, key: &str) -> Option<i64> {
70 character_state.character.custom_values.0.get(key).copied()
71}
72
73fn quest_by_code(lookups: &ContentLookups, code: &str) -> Option<Uuid> {
74 lookups.quest_by_code.get(code).copied()
75}
76
77fn quest_by_type_and_number(lookups: &ContentLookups, type_: &str, number: &str) -> Option<Uuid> {
78 let code = format!("loop_task.{type_}.{number}");
79 quest_by_code(lookups, &code)
80}
81
82fn quest_by_type_and_int(lookups: &ContentLookups, type_: &str, number: i64) -> Option<Uuid> {
83 let code = format!("loop_task.{type_}.{number}");
84 quest_by_code(lookups, &code)
85}
86
87fn band_for_chapter(chapter: i64) -> i64 {
94 if chapter < 16 {
95 1
96 } else if chapter <= 40 {
97 2
98 } else if chapter <= 80 {
99 3
100 } else {
101 4
102 }
103}
104
105fn currency_balance(character_state: &CharacterState, id: Uuid) -> i64 {
107 character_state
108 .currencies
109 .iter()
110 .find(|c| c.currency_id == id)
111 .map_or(0, |c| c.amount)
112}
113
114fn case_step_startable(config: &GameConfig, character_state: &CharacterState) -> bool {
119 if character_state
120 .character
121 .item_case_upgrade_finish_at
122 .is_some()
123 {
124 return false;
125 }
126 let next_level = character_state.character.item_case_level.max(1) + 1;
129 let chapter = character_state.character.current_chapter_level;
130 config
131 .item_cases_settings
132 .iter()
133 .find(|s| s.level == next_level)
134 .is_some_and(|s| {
135 s.required_chapter_level <= chapter
136 && s.upgrade_cost
137 .iter()
138 .all(|c| currency_balance(character_state, c.currency_id) >= c.amount)
139 })
140}
141
142fn loop_task_doable(task: i64, config: &GameConfig, character_state: &CharacterState) -> bool {
149 let chapter = character_state.character.current_chapter_level;
150 let g = &config.gatings;
151 let skills_open = chapter >= g.navbar_navigation.skills_button_unlock_chapter;
152 let pets_open = chapter >= g.navbar_navigation.pets_button_unlock_chapter;
153 let bal = |id: u128| currency_balance(character_state, Uuid::from_u128(id));
154 match task {
155 1 | 2 | 6 => true,
156 3 => skills_open && bal(SC_CURRENCY) >= 10,
158 4 => character_state.inventory.iter().any(|i| !i.is_equipped),
160 5 => {
163 skills_open
164 && character_state
165 .all_abilities
166 .iter()
167 .any(|a| a.shards_amount >= a.level)
168 }
169 7 => skills_open && bal(GEMS_CURRENCY) >= 100,
171 8 => {
173 let dungeons_open = chapter >= g.navbar_navigation.dungeon_button_unlock_chapter;
174 let front_enemy = crate::mechanics::balance::enemy_power_for_chapter(chapter.max(0));
177 dungeons_open
178 || (character_state.character.power as f64) >= BOSS_WINNABLE_RATIO * front_enemy
179 }
180 9 => pets_open && bal(BEAST_TOKEN_CURRENCY) >= 1,
182 10 => pets_open && !character_state.all_pets.is_empty() && bal(BEAST_TOKEN_CURRENCY) >= 1,
184 11 => !character_state.inventory.is_empty(),
185 12 => case_step_startable(config, character_state),
186 _ => false,
187 }
188}
189
190fn band_quest(lookups: &ContentLookups, n: i64, band: i64) -> Option<Uuid> {
195 if band <= 1 {
196 return None;
197 }
198 quest_by_code(lookups, &format!("loop_task.loop.{n}.{band}"))
199}
200
201fn next_milestone(
214 config: &GameConfig,
215 lookups: &ContentLookups,
216 character_state: &CharacterState,
217 type_: &str,
218 frequency: i64,
219 quantity: i64,
220) -> Option<Uuid> {
221 let without = cv_i64(
222 character_state,
223 &format!("loop_tasks.without_milestone.{type_}"),
224 );
225 if without < frequency {
226 return None;
227 }
228 let current = match type_ {
229 "level" => character_state.character.character_level,
230 "stage" => character_state.character.current_chapter_level,
231 _ => return None,
232 };
233 let mut best: Option<(i64, Uuid)> = None;
234 for n in 1..=quantity {
235 let Some(qid) = quest_by_type_and_int(lookups, type_, n) else {
236 continue;
237 };
238 let Some(target) = config
239 .quest(qid)
240 .and_then(|q| q.progress_behavior.as_deref())
241 .and_then(parse_behavior_target)
242 else {
243 continue;
244 };
245 let better = match best {
246 None => true,
247 Some((bt, _)) => target < bt,
248 };
249 if target > current && better {
250 best = Some((target, qid));
251 }
252 }
253 best.map(|(_, qid)| qid)
254}
255
256fn parse_behavior_target(behavior: &str) -> Option<i64> {
260 behavior.rsplit('_').next()?.parse().ok()
261}
262
263fn drain_random_i64(slots: &mut Vec<i64>, random: &GameRng) -> i64 {
264 if slots.is_empty() {
265 return 0;
266 }
267 let idx = random.randint(0, slots.len() as i64) as usize;
268 slots.remove(idx)
269}
270
271fn tick_milestone_native(
279 events: &mut Vec<OverlordEvent>,
280 character_state: &CharacterState,
281 type_: &str,
282) {
283 let key = format!("loop_tasks.without_milestone.{type_}");
284 let current = cv_i64(character_state, &key);
285 events.push(set_custom_value(&key, current + 1));
286}
287
288pub fn advance_loop(
290 events: &mut Vec<OverlordEvent>,
291 config: &GameConfig,
292 lookups: &ContentLookups,
293 character_state: &CharacterState,
294) {
295 let last_loop_task = cv_i64(character_state, "loop_tasks.last");
296
297 let arena_after = cv_opt(character_state, "loop_tasks.arena_after");
298 let gold_after = cv_opt(character_state, "loop_tasks.gold_dungeon_after");
299 let cookie_after = cv_opt(character_state, "loop_tasks.cookie_dungeon_after");
300 let blueprint_after = cv_opt(character_state, "loop_tasks.blueprint_dungeon_after");
301
302 let quest = if arena_after == Some(last_loop_task) {
303 events.push(set_custom_value("loop_tasks.arena_after", 0));
304 quest_by_type_and_number(lookups, "loop", "arena")
305 } else if gold_after == Some(last_loop_task) {
306 events.push(set_custom_value("loop_tasks.gold_dungeon_after", 0));
307 quest_by_type_and_number(lookups, "loop", "gold_dungeon")
308 } else if cookie_after == Some(last_loop_task) {
309 events.push(set_custom_value("loop_tasks.cookie_dungeon_after", 0));
310 quest_by_type_and_number(lookups, "loop", "cookie_dungeon")
311 } else if blueprint_after == Some(last_loop_task) {
312 events.push(set_custom_value("loop_tasks.blueprint_dungeon_after", 0));
316 quest_by_type_and_number(lookups, "loop", "blueprint_dungeon")
317 } else {
318 None
319 };
320
321 if let Some(qid) = quest {
322 events.push(OverlordEvent::NewQuests {
323 quest_ids: vec![qid],
324 });
325 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
326 tick_milestone_native(events, character_state, "stage");
327 tick_milestone_native(events, character_state, "level");
328 return;
329 }
330
331 if let Some(qid) = next_milestone(
332 config,
333 lookups,
334 character_state,
335 "level",
336 LEVEL_MILESTONE_FREQUENCY,
337 LEVEL_MILESTONE_QUANTITY,
338 ) {
339 events.push(OverlordEvent::NewQuests {
340 quest_ids: vec![qid],
341 });
342 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
343 events.push(set_custom_value("loop_tasks.without_milestone.level", 0));
344 return;
345 }
346 if let Some(qid) = next_milestone(
347 config,
348 lookups,
349 character_state,
350 "stage",
351 STAGE_MILESTONE_FREQUENCY,
352 STAGE_MILESTONE_QUANTITY,
353 ) {
354 events.push(OverlordEvent::NewQuests {
355 quest_ids: vec![qid],
356 });
357 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
358 events.push(set_custom_value("loop_tasks.without_milestone.stage", 0));
359 return;
360 }
361
362 let last_loop_task = cv_i64(character_state, "loop_tasks.last");
363
364 let start = LOOP_POOL
370 .iter()
371 .position(|&t| t == last_loop_task)
372 .map(|p| p + 1)
373 .unwrap_or(0);
374 let mut next_loop_task = None;
375 for offset in 0..LOOP_POOL.len() {
376 let task = LOOP_POOL[(start + offset) % LOOP_POOL.len()];
377 let authored = quest_by_type_and_int(lookups, "loop", task).is_some();
382 if authored && loop_task_doable(task, config, character_state) {
383 next_loop_task = Some(task);
384 break;
385 }
386 }
387 let Some(next_loop_task) = next_loop_task else {
388 return; };
390
391 let band = band_for_chapter(character_state.character.current_chapter_level);
395 let qid = band_quest(lookups, next_loop_task, band)
396 .or_else(|| quest_by_type_and_int(lookups, "loop", next_loop_task));
397 if let Some(qid) = qid {
398 events.push(set_custom_value("loop_tasks.last", next_loop_task));
399 events.push(OverlordEvent::NewQuests {
400 quest_ids: vec![qid],
401 });
402 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
403 tick_milestone_native(events, character_state, "stage");
404 tick_milestone_native(events, character_state, "level");
405 }
406}
407
408pub fn prepare_loop(
411 events: &mut Vec<OverlordEvent>,
412 config: &GameConfig,
413 character_state: &CharacterState,
414 random: &GameRng,
415) {
416 let mut slots = vec![3i64, 5, 7];
417 let chapter = character_state.character.current_chapter_level;
418
419 let gold_chapter = config
420 .dungeon_template(Uuid::parse_str(GOLD_DUNGEON_ID).unwrap())
421 .map(|d| d.chapter_level_unlock)
422 .unwrap_or(i64::MAX);
423 let cookie_chapter = config
424 .dungeon_template(Uuid::parse_str(COOKIE_DUNGEON_ID).unwrap())
425 .map(|d| d.chapter_level_unlock)
426 .unwrap_or(i64::MAX);
427 let blueprint_chapter = config
428 .dungeon_template(Uuid::parse_str(BLUEPRINT_DUNGEON_ID).unwrap())
429 .map(|d| d.chapter_level_unlock)
430 .unwrap_or(i64::MAX);
431
432 if chapter >= gold_chapter {
433 events.push(set_custom_value(
434 "loop_tasks.gold_dungeon_after",
435 drain_random_i64(&mut slots, random),
436 ));
437 }
438 if chapter >= cookie_chapter {
439 events.push(set_custom_value(
440 "loop_tasks.cookie_dungeon_after",
441 drain_random_i64(&mut slots, random),
442 ));
443 }
444 if chapter >= blueprint_chapter {
445 events.push(set_custom_value(
446 "loop_tasks.blueprint_dungeon_after",
447 drain_random_i64(&mut slots, random),
448 ));
449 }
450
451 let arena_chapter = config
452 .gatings
453 .sidebar_navigation
454 .arena_button_unlock_chapter;
455 let arena_previous_task_index = 4;
456 if chapter >= arena_chapter {
457 events.push(set_custom_value(
458 "loop_tasks.arena_after",
459 arena_previous_task_index,
460 ));
461 }
462}
463
464pub fn on_finish_regular_loop_task(
465 _events: &mut [OverlordEvent],
466 _character_state: &CharacterState,
467) {
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use configs::tests_game_config::generate_game_config_for_tests;
474 use essences::currency::CurrencyUnit;
475
476 fn pool_config(skills: i64, pets: i64, dungeon: i64) -> GameConfig {
478 let mut config = generate_game_config_for_tests();
479 config
480 .gatings
481 .navbar_navigation
482 .skills_button_unlock_chapter = skills;
483 config.gatings.navbar_navigation.pets_button_unlock_chapter = pets;
484 config
485 .gatings
486 .navbar_navigation
487 .dungeon_button_unlock_chapter = dungeon;
488 config
489 }
490
491 fn state_at(chapter: i64, last_loop_task: i64) -> CharacterState {
492 let mut character_state = CharacterState::default();
493 character_state.character.current_chapter_level = chapter;
494 character_state
495 .character
496 .custom_values
497 .0
498 .insert("loop_tasks.last".to_string(), last_loop_task);
499 character_state
500 }
501
502 fn give_currency(character_state: &mut CharacterState, id: u128, amount: i64) {
503 character_state.currencies.push(CurrencyUnit {
504 currency_id: Uuid::from_u128(id),
505 amount,
506 });
507 }
508
509 fn assigned_task(config: &GameConfig, character_state: &CharacterState) -> i64 {
513 let mut lookups = ContentLookups::default();
514 for &task in LOOP_POOL {
515 lookups.quest_by_code.insert(
516 format!("loop_task.loop.{task}"),
517 Uuid::from_u128(task as u128),
518 );
519 }
520 let mut events = Vec::new();
521 advance_loop(&mut events, config, &lookups, character_state);
522 events
523 .into_iter()
524 .find_map(|e| match e {
525 OverlordEvent::SetCustomValue { key, value } if key == "loop_tasks.last" => {
526 Some(value)
527 }
528 _ => None,
529 })
530 .expect("advance_loop should set loop_tasks.last")
531 }
532
533 #[test]
534 fn empty_state_skips_bank_gated_tasks_to_anchor() {
535 let config = pool_config(11, 16, 20);
538 assert_eq!(assigned_task(&config, &state_at(1, 2)), 6);
539 }
540
541 #[test]
542 fn bank_funded_skill_summon_is_served_at_unlock() {
543 let config = pool_config(11, 16, 20);
545 let mut state = state_at(11, 2);
546 give_currency(&mut state, SC_CURRENCY, 10);
547 assert_eq!(assigned_task(&config, &state), 3);
548 }
549
550 #[test]
551 fn skill_summon_skipped_without_bank_even_at_unlock() {
552 let config = pool_config(11, 16, 20);
555 assert_eq!(assigned_task(&config, &state_at(11, 2)), 6);
556 }
557
558 #[test]
559 fn gems_task_served_only_with_balance() {
560 let config = pool_config(11, 16, 20);
561 let mut rich = state_at(11, 6);
563 give_currency(&mut rich, GEMS_CURRENCY, 150);
564 assert_eq!(assigned_task(&config, &rich), 7);
565 assert_eq!(assigned_task(&config, &state_at(11, 6)), 1);
570 }
571
572 #[test]
573 fn boss_task_served_when_dungeons_open() {
574 let config = pool_config(11, 16, 20);
577 assert_eq!(assigned_task(&config, &state_at(25, 7)), 8);
578 }
579
580 #[test]
581 fn pool_never_hangs_from_any_position_with_empty_state() {
582 let config = pool_config(11, 16, 20);
585 for &last in LOOP_POOL {
586 let task = assigned_task(&config, &state_at(1, last));
587 assert!(
588 matches!(task, 1 | 2 | 6),
589 "from last={last} landed on {task}, expected an anchor"
590 );
591 }
592 }
593
594 #[test]
595 fn pool_wraps_after_the_last_task() {
596 let config = pool_config(11, 16, 20);
597 assert_eq!(assigned_task(&config, &state_at(1, 12)), 1);
598 }
599
600 #[test]
601 fn unauthored_pool_tasks_are_skipped() {
602 let config = pool_config(11, 16, 20);
606 let mut lookups = ContentLookups::default();
607 for task in 1..=7i64 {
608 lookups.quest_by_code.insert(
609 format!("loop_task.loop.{task}"),
610 Uuid::from_u128(task as u128),
611 );
612 }
613 let mut events = Vec::new();
614 advance_loop(&mut events, &config, &lookups, &state_at(25, 7));
615 let assigned = events
616 .into_iter()
617 .find_map(|e| match e {
618 OverlordEvent::SetCustomValue { key, value } if key == "loop_tasks.last" => {
619 Some(value)
620 }
621 _ => None,
622 })
623 .expect("advance_loop should set loop_tasks.last");
624 assert!(
625 (1..=7).contains(&assigned),
626 "landed on unauthored task {assigned}"
627 );
628 }
629
630 #[test]
631 fn parse_behavior_target_reads_reach_thresholds() {
632 assert_eq!(parse_behavior_target("loop_task_reach_level_28"), Some(28));
635 assert_eq!(
636 parse_behavior_target("loop_task_reach_chapter_level_7"),
637 Some(7)
638 );
639 assert_eq!(parse_behavior_target("loop_task_reach_level_50"), Some(50));
640 assert_eq!(parse_behavior_target("loop_task_pvp_win"), None);
642 assert_eq!(parse_behavior_target("increment_one"), None);
643 }
644
645 #[test]
646 fn band_scales_with_chapter() {
647 assert_eq!(band_for_chapter(1), 1);
649 assert_eq!(band_for_chapter(15), 1);
650 assert_eq!(band_for_chapter(16), 2);
651 assert_eq!(band_for_chapter(40), 2);
652 assert_eq!(band_for_chapter(41), 3);
653 assert_eq!(band_for_chapter(80), 3);
654 assert_eq!(band_for_chapter(81), 4);
655 assert_eq!(band_for_chapter(120), 4);
656 let lookups = ContentLookups::default();
658 assert_eq!(band_quest(&lookups, 2, 1), None);
659 assert_eq!(band_quest(&lookups, 2, 2), None); }
661}