overlord_event_system/mechanics/
loop_tasks.rs1use 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 = 6;
18const LEVEL_MILESTONE_QUANTITY: i64 = 14;
24const STAGE_MILESTONE_FREQUENCY: i64 = 6;
25const STAGE_MILESTONE_QUANTITY: i64 = 13;
26const LOOP_TASK_QUANTITY: i64 = 7;
27
28const GOLD_DUNGEON_ID: &str = "019aee96-7303-7d3e-a382-d7776687d24f";
29const COOKIE_DUNGEON_ID: &str = "019a9206-800b-781e-8998-38cdc6e9826e";
30const BLUEPRINT_DUNGEON_ID: &str = "019d2eca-9508-71c9-abb3-a6fc17474502";
31
32fn set_custom_value(key: &str, value: i64) -> OverlordEvent {
34 OverlordEvent::SetCustomValue {
35 key: key.to_string(),
36 value,
37 }
38}
39
40fn cv_i64(character_state: &CharacterState, key: &str) -> i64 {
41 cv_opt(character_state, key).unwrap_or(0)
42}
43
44fn cv_opt(character_state: &CharacterState, key: &str) -> Option<i64> {
49 character_state.character.custom_values.0.get(key).copied()
50}
51
52fn quest_by_code(lookups: &ContentLookups, code: &str) -> Option<Uuid> {
53 lookups.quest_by_code.get(code).copied()
54}
55
56fn quest_by_type_and_number(lookups: &ContentLookups, type_: &str, number: &str) -> Option<Uuid> {
57 let code = format!("loop_task.{type_}.{number}");
58 quest_by_code(lookups, &code)
59}
60
61fn quest_by_type_and_int(lookups: &ContentLookups, type_: &str, number: i64) -> Option<Uuid> {
62 let code = format!("loop_task.{type_}.{number}");
63 quest_by_code(lookups, &code)
64}
65
66fn band_for_chapter(chapter: i64) -> i64 {
73 if chapter < 16 {
74 1
75 } else if chapter <= 40 {
76 2
77 } else {
78 3
79 }
80}
81
82fn band_quest(lookups: &ContentLookups, n: i64, band: i64) -> Option<Uuid> {
87 if band <= 1 {
88 return None;
89 }
90 quest_by_code(lookups, &format!("loop_task.loop.{n}.{band}"))
91}
92
93fn next_milestone(
106 config: &GameConfig,
107 lookups: &ContentLookups,
108 character_state: &CharacterState,
109 type_: &str,
110 frequency: i64,
111 quantity: i64,
112) -> Option<Uuid> {
113 let without = cv_i64(
114 character_state,
115 &format!("loop_tasks.without_milestone.{type_}"),
116 );
117 if without < frequency {
118 return None;
119 }
120 let current = match type_ {
121 "level" => character_state.character.character_level,
122 "stage" => character_state.character.current_chapter_level,
123 _ => return None,
124 };
125 let mut best: Option<(i64, Uuid)> = None;
126 for n in 1..=quantity {
127 let Some(qid) = quest_by_type_and_int(lookups, type_, n) else {
128 continue;
129 };
130 let Some(target) = config
131 .quest(qid)
132 .and_then(|q| q.progress_behavior.as_deref())
133 .and_then(parse_behavior_target)
134 else {
135 continue;
136 };
137 let better = match best {
138 None => true,
139 Some((bt, _)) => target < bt,
140 };
141 if target > current && better {
142 best = Some((target, qid));
143 }
144 }
145 best.map(|(_, qid)| qid)
146}
147
148fn parse_behavior_target(behavior: &str) -> Option<i64> {
152 behavior.rsplit('_').next()?.parse().ok()
153}
154
155fn drain_random_i64(slots: &mut Vec<i64>, random: &GameRng) -> i64 {
156 if slots.is_empty() {
157 return 0;
158 }
159 let idx = random.randint(0, slots.len() as i64) as usize;
160 slots.remove(idx)
161}
162
163fn tick_milestone_native(
171 events: &mut Vec<OverlordEvent>,
172 character_state: &CharacterState,
173 type_: &str,
174) {
175 let key = format!("loop_tasks.without_milestone.{type_}");
176 let current = cv_i64(character_state, &key);
177 events.push(set_custom_value(&key, current + 1));
178}
179
180pub fn advance_loop(
182 events: &mut Vec<OverlordEvent>,
183 config: &GameConfig,
184 lookups: &ContentLookups,
185 character_state: &CharacterState,
186) {
187 let last_loop_task = cv_i64(character_state, "loop_tasks.last");
188
189 let arena_after = cv_opt(character_state, "loop_tasks.arena_after");
190 let gold_after = cv_opt(character_state, "loop_tasks.gold_dungeon_after");
191 let cookie_after = cv_opt(character_state, "loop_tasks.cookie_dungeon_after");
192
193 let quest = if arena_after == Some(last_loop_task) {
194 events.push(set_custom_value("loop_tasks.arena_after", 0));
195 quest_by_type_and_number(lookups, "loop", "arena")
196 } else if gold_after == Some(last_loop_task) {
197 events.push(set_custom_value("loop_tasks.gold_dungeon_after", 0));
198 quest_by_type_and_number(lookups, "loop", "gold_dungeon")
199 } else if cookie_after == Some(last_loop_task) {
200 events.push(set_custom_value("loop_tasks.cookie_dungeon_after", 0));
201 quest_by_type_and_number(lookups, "loop", "cookie_dungeon")
202 } else {
203 None
204 };
205
206 if let Some(qid) = quest {
207 events.push(OverlordEvent::NewQuests {
208 quest_ids: vec![qid],
209 });
210 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
211 tick_milestone_native(events, character_state, "stage");
212 tick_milestone_native(events, character_state, "level");
213 return;
214 }
215
216 if let Some(qid) = next_milestone(
217 config,
218 lookups,
219 character_state,
220 "level",
221 LEVEL_MILESTONE_FREQUENCY,
222 LEVEL_MILESTONE_QUANTITY,
223 ) {
224 events.push(OverlordEvent::NewQuests {
225 quest_ids: vec![qid],
226 });
227 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
228 events.push(set_custom_value("loop_tasks.without_milestone.level", 0));
229 return;
230 }
231 if let Some(qid) = next_milestone(
232 config,
233 lookups,
234 character_state,
235 "stage",
236 STAGE_MILESTONE_FREQUENCY,
237 STAGE_MILESTONE_QUANTITY,
238 ) {
239 events.push(OverlordEvent::NewQuests {
240 quest_ids: vec![qid],
241 });
242 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
243 events.push(set_custom_value("loop_tasks.without_milestone.stage", 0));
244 return;
245 }
246
247 let last_loop_task = cv_i64(character_state, "loop_tasks.last");
248 let mut next_loop_task = last_loop_task + 1;
249
250 let skills_unlock_chapter = config
264 .gatings
265 .navbar_navigation
266 .skills_button_unlock_chapter;
267 let skills_locked = character_state.character.current_chapter_level < skills_unlock_chapter;
268 for _ in 0..LOOP_TASK_QUANTITY {
269 if next_loop_task > LOOP_TASK_QUANTITY {
270 next_loop_task = 1;
271 }
272 if skills_locked && matches!(next_loop_task, 3 | 5 | 7) {
273 next_loop_task += 1;
274 } else {
275 break;
276 }
277 }
278
279 if next_loop_task > LOOP_TASK_QUANTITY {
280 next_loop_task = 1;
281 }
282
283 let band = band_for_chapter(character_state.character.current_chapter_level);
286 let qid = band_quest(lookups, next_loop_task, band)
287 .or_else(|| quest_by_type_and_int(lookups, "loop", next_loop_task));
288 if let Some(qid) = qid {
289 events.push(set_custom_value("loop_tasks.last", next_loop_task));
290 events.push(OverlordEvent::NewQuests {
291 quest_ids: vec![qid],
292 });
293 events.push(OverlordEvent::UpdateActiveLoopTaskId { quest_id: qid });
294 tick_milestone_native(events, character_state, "stage");
295 tick_milestone_native(events, character_state, "level");
296 }
297}
298
299pub fn prepare_loop(
302 events: &mut Vec<OverlordEvent>,
303 config: &GameConfig,
304 character_state: &CharacterState,
305 random: &GameRng,
306) {
307 let mut slots = vec![3i64, 5, 7];
308 let chapter = character_state.character.current_chapter_level;
309
310 let gold_chapter = config
311 .dungeon_template(Uuid::parse_str(GOLD_DUNGEON_ID).unwrap())
312 .map(|d| d.chapter_level_unlock)
313 .unwrap_or(i64::MAX);
314 let cookie_chapter = config
315 .dungeon_template(Uuid::parse_str(COOKIE_DUNGEON_ID).unwrap())
316 .map(|d| d.chapter_level_unlock)
317 .unwrap_or(i64::MAX);
318 let blueprint_chapter = config
319 .dungeon_template(Uuid::parse_str(BLUEPRINT_DUNGEON_ID).unwrap())
320 .map(|d| d.chapter_level_unlock)
321 .unwrap_or(i64::MAX);
322
323 if chapter >= gold_chapter {
324 events.push(set_custom_value(
325 "loop_tasks.gold_dungeon_after",
326 drain_random_i64(&mut slots, random),
327 ));
328 }
329 if chapter >= cookie_chapter {
330 events.push(set_custom_value(
331 "loop_tasks.cookie_dungeon_after",
332 drain_random_i64(&mut slots, random),
333 ));
334 }
335 if chapter >= blueprint_chapter {
336 events.push(set_custom_value(
337 "loop_tasks.blueprint_dungeon_after",
338 drain_random_i64(&mut slots, random),
339 ));
340 }
341
342 let arena_chapter = config
343 .gatings
344 .sidebar_navigation
345 .arena_button_unlock_chapter;
346 let arena_previous_task_index = 4;
347 if chapter >= arena_chapter {
348 events.push(set_custom_value(
349 "loop_tasks.arena_after",
350 arena_previous_task_index,
351 ));
352 }
353}
354
355pub fn on_finish_regular_loop_task(
356 _events: &mut [OverlordEvent],
357 _character_state: &CharacterState,
358) {
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use configs::tests_game_config::generate_game_config_for_tests;
365
366 fn assigned_slot(
372 skills_unlock_chapter: i64,
373 current_chapter_level: i64,
374 last_loop_task: i64,
375 ) -> i64 {
376 let mut config = generate_game_config_for_tests();
377 config
378 .gatings
379 .navbar_navigation
380 .skills_button_unlock_chapter = skills_unlock_chapter;
381
382 let mut lookups = ContentLookups::default();
383 for slot in 1..=LOOP_TASK_QUANTITY {
384 let id = Uuid::from_u128(slot as u128);
386 lookups
387 .quest_by_code
388 .insert(format!("loop_task.loop.{slot}"), id);
389 }
390
391 let mut character_state = CharacterState::default();
392 character_state.character.current_chapter_level = current_chapter_level;
393 character_state
394 .character
395 .custom_values
396 .0
397 .insert("loop_tasks.last".to_string(), last_loop_task);
398
399 let mut events = Vec::new();
400 advance_loop(&mut events, &config, &lookups, &character_state);
401
402 events
403 .into_iter()
404 .find_map(|e| match e {
405 OverlordEvent::SetCustomValue { key, value } if key == "loop_tasks.last" => {
406 Some(value)
407 }
408 _ => None,
409 })
410 .expect("advance_loop should set loop_tasks.last")
411 }
412
413 #[test]
414 fn skill_loop_task_skipped_below_unlock_chapter() {
415 assert_eq!(assigned_slot(5, 1, 2), 4);
418 assert_eq!(assigned_slot(5, 1, 4), 6);
420 }
421
422 #[test]
423 fn skill_loop_task_kept_at_unlock_chapter() {
424 assert_eq!(assigned_slot(5, 5, 2), 3);
426 assert_eq!(assigned_slot(5, 5, 4), 5);
427 }
428
429 #[test]
430 fn spend_gems_loop_task_skipped_below_unlock_chapter() {
431 assert_eq!(assigned_slot(5, 1, 6), 1);
434 }
435
436 #[test]
437 fn spend_gems_loop_task_kept_at_unlock_chapter() {
438 assert_eq!(assigned_slot(5, 5, 6), 7);
440 }
441
442 #[test]
443 fn parse_behavior_target_reads_reach_thresholds() {
444 assert_eq!(parse_behavior_target("loop_task_reach_level_28"), Some(28));
447 assert_eq!(
448 parse_behavior_target("loop_task_reach_chapter_level_7"),
449 Some(7)
450 );
451 assert_eq!(parse_behavior_target("loop_task_reach_level_50"), Some(50));
452 assert_eq!(parse_behavior_target("loop_task_pvp_win"), None);
454 assert_eq!(parse_behavior_target("increment_one"), None);
455 }
456
457 #[test]
458 fn band_scales_with_chapter() {
459 assert_eq!(band_for_chapter(1), 1);
461 assert_eq!(band_for_chapter(15), 1);
462 assert_eq!(band_for_chapter(16), 2);
463 assert_eq!(band_for_chapter(40), 2);
464 assert_eq!(band_for_chapter(41), 3);
465 assert_eq!(band_for_chapter(90), 3);
466 let lookups = ContentLookups::default();
468 assert_eq!(band_quest(&lookups, 2, 1), None);
469 assert_eq!(band_quest(&lookups, 2, 2), None); }
471}