overlord_event_system/mechanics/
loop_tasks.rs

1//! Native Rust implementation of the loop-task pacing logic.
2//!
3//! Drives the daily "loop task" pacing: arena/dungeon insertions, milestone
4//! quests, and the regular quest cycle. Called by the native quest ports in
5//! `behaviors::additional_quests` via `advance_loop` /
6//! `prepare_loop` / `on_finish_regular_loop_task`.
7
8use 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;
18// Extended past the day-7 progression wall (was 10 = cap at character level 30,
19// stage 10 = chapter 11 which free players pass on day 1). Beyond the cap the
20// loop has no "progress acknowledged" milestone and degenerates into a flat
21// 7-task treadmill; the higher milestones (loop_task.{level,stage}.N quests)
22// keep the ladder alive into the ch40 / level-50 range with scaled rewards.
23const 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
32/// SetCustomValue helper for the native (enum-event) path.
33fn 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
44/// absent custom value (`()`) from a present `0`: comparisons like
45/// `last_loop_task == custom_values["loop_tasks.arena_after"]` are `false`
46/// when the key is missing because `int == ()` is false. Callers that need
47/// that distinction must use this instead of `cv_i64`.
48fn 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
66/// Difficulty BAND for the player's chapter — picks the SIZE tier of a scalable
67/// core loop task so its target scales with the player (Legend-of-Mushroom model:
68/// "kill 5" early → "kill ~20" late, ~constant effort because per-fight throughput
69/// grows too). Band 1 = the base `loop_task.loop.<N>` quest; bands 2/3 = the
70/// `loop_task.loop.<N>.<band>` size variants. Thresholds align with the
71/// systems-online wave (skills ch11 / pets ch16) and the milestone cap (ch40).
72fn 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
82/// Band-`band` size variant of core loop slot `n` (`loop_task.loop.<n>.<band>`),
83/// or `None` for band 1 or a slot with no variant — the caller then falls back to
84/// the base `loop_task.loop.<n>`. Only the scalable slots author band-2/3 variants;
85/// the gated skill slots (3/5/7) have none and always use the base quest.
86fn 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
93/// Selects the PROXIMATE (in-time) milestone tier for `type_` ("level"/"stage"):
94/// the tier whose target is the SMALLEST strictly above the player's current
95/// level/chapter, paced once per `frequency` core completions. The old
96/// `last_milestone + 1` fixed-sequence selector decoupled the served tier from
97/// real progress, so it either raced ahead ("Reach Level 28" handed to a level-12
98/// player → blocks the single active slot) or lagged behind ("Clear Stage 1-7" at
99/// stage 6-1 → stale/trivial). Proximate selection always serves the NEXT tier
100/// just ahead — never stale, never far — so the milestone is achievable soon and
101/// "feels great" when hit (user: "milestones are good IF in time"). The tier's
102/// target lives in its `reach_level_<N>` / `reach_chapter_level_<N>` behavior
103/// suffix. Tiers cap at level 50 / chapter 40, so milestones naturally phase out
104/// before the late-game soft walls — no wall-blocking. `quantity` bounds the scan.
105fn 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
148/// Trailing integer of a `loop_task_reach_level_<N>` /
149/// `loop_task_reach_chapter_level_<N>` behavior name — the milestone tier's
150/// target level/chapter. `None` for any other shape (that tier is skipped).
151fn 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
163// ---------------------------------------------------------------------------
164// Native (Vec<OverlordEvent>) loop_tasks logic.
165//
166// `advance_loop` / `prepare_loop` / `on_finish_regular_loop_task`
167// push into a plain `Vec<OverlordEvent>` (same branch order, same RNG draw
168// ---------------------------------------------------------------------------
169
170fn 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
180/// Native port of the loop-task `advance_loop` logic.
181pub 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    // OVT-2405: skip the skill-gated loop slots (3 "Summon 10 Skills",
251    // 5 "Upgrade a Skill", 7 "Spend 100 Gems" — gems are only spendable on
252    // skills) until the player reaches
253    // `navbar_navigation.skills_button_unlock_chapter`. Without this a player is
254    // handed a skill task before skills exist and the loop hangs forever
255    // (advance_loop only runs when the active task completes).
256    //
257    // Re-check after each bump, bounded by LOOP_TASK_QUANTITY, instead of a
258    // single `+1` that assumed slots 1/4/6 are never gated and gated slots are
259    // non-adjacent. That invariant is implicit, and a slot reorder (or two
260    // adjacent gated slots) would let a single bump land on another gated slot
261    // and re-introduce the hang — exactly the OVT-2405 failure class. The
262    // bounded loop lands on a reachable slot for any layout.
263    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    // Scalable slots serve a size variant for the player's band (kill/chest/sell/
284    // waves grow with progress); gated skill slots have no variant → base quest.
285    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
299/// Native port of the loop-task `prepare_loop` logic.
300/// Consumes RNG in the IDENTICAL order (gold → cookie → blueprint slot draws).
301pub 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    /// Drives `advance_loop` for a character whose previous loop task was
367    /// `last_loop_task`, and returns the slot the run wrote to `loop_tasks.last`
368    /// — i.e. the slot that was actually assigned. Registers a quest code for
369    /// every slot 1..=7 so any landing slot (including the slot-7 -> wrap-to-1
370    /// case) resolves to a quest.
371    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            // Distinct, deterministic UUID per slot.
385            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        // Skills unlock at chapter 5; player is at chapter 1. Slot 3 (Summon
416        // Skills) must be skipped to slot 4 so the loop never hangs.
417        assert_eq!(assigned_slot(5, 1, 2), 4);
418        // Slot 5 (Upgrade a Skill) likewise skips to slot 6.
419        assert_eq!(assigned_slot(5, 1, 4), 6);
420    }
421
422    #[test]
423    fn skill_loop_task_kept_at_unlock_chapter() {
424        // At/above the unlock chapter the skill tasks are reachable, so they stay.
425        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        // Slot 7 (Spend 100 Gems) has no gem sink without skill access, so it is
432        // skipped: 7 -> 8 -> wraps to slot 1.
433        assert_eq!(assigned_slot(5, 1, 6), 1);
434    }
435
436    #[test]
437    fn spend_gems_loop_task_kept_at_unlock_chapter() {
438        // Once skills are unlocked, gems can be spent, so slot 7 stays.
439        assert_eq!(assigned_slot(5, 5, 6), 7);
440    }
441
442    #[test]
443    fn parse_behavior_target_reads_reach_thresholds() {
444        // The proximate-milestone selector reads each tier's target from its
445        // reach-level / reach-chapter behavior suffix.
446        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        // Non-reach behaviors have no numeric target → that tier is skipped.
453        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        // Scalable core tasks pick a larger size tier as the player advances.
460        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        // Band 1 has no `.band` variant (uses the base quest); 2/3 do.
467        let lookups = ContentLookups::default();
468        assert_eq!(band_quest(&lookups, 2, 1), None);
469        assert_eq!(band_quest(&lookups, 2, 2), None); // absent in empty lookups → caller falls back
470    }
471}