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
17// Milestones come every 5 completions: at the target cadence (~4-6 clears/day
18// + loop churn) a wider spacing lets whole sessions pass without an
19// acknowledged milestone. Quantities bound the tier
20// scan; ladders extend to level 80 / chapter 80 (missing tiers are skipped
21// gracefully, so the bound is safe to raise before the content lands).
22const LEVEL_MILESTONE_FREQUENCY: i64 = 5;
23const LEVEL_MILESTONE_QUANTITY: i64 = 18;
24const STAGE_MILESTONE_FREQUENCY: i64 = 5;
25const STAGE_MILESTONE_QUANTITY: i64 = 19;
26
27/// The regular loop-task chain draws from this POOL of micro-tasks (not a
28/// fixed ring). Ids 1-7 keep
29/// the legacy ring meanings (their content is live): 1 chest-opens, 2 kills,
30/// 3 skill summons, 4 sells, 5 skill upgrade, 6 waves, 7 spend-gems. Catalog
31/// additions: 8 boss win, 9 pet summon, 10 pet upgrade, 11 equip items,
32/// 12 start a chest-ladder step. An AFK-claim task is deliberately ABSENT — in a
33/// single-active-slot chain it is stall-prone (accrual takes hours) and the
34/// same objective already exists as a daily.
35const LOOP_POOL: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
36
37// Currency uuids for doability bank checks (stable content ids, same ones the
38// deploy-time validator asserts quest content against).
39const 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
43/// A boss task is servable when the front boss is not hopeless (power within
44/// this fraction of the front chapter's enemy power — well below the sigmoid
45/// crossing ~0.29-0.35, so only truly unwinnable states are filtered) OR when
46/// dungeons are open (dungeon bosses are re-fightable at chosen difficulty).
47const 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
53/// SetCustomValue helper for the native (enum-event) path.
54fn 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
65/// absent custom value (`()`) from a present `0`: comparisons like
66/// `last_loop_task == custom_values["loop_tasks.arena_after"]` are `false`
67/// when the key is missing because `int == ()` is false. Callers that need
68/// that distinction must use this instead of `cv_i64`.
69fn 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
87/// Difficulty BAND for the player's chapter — picks the SIZE tier of a scalable
88/// core loop task so its target scales with the player (Legend-of-Mushroom model:
89/// "kill 5" early → "kill ~20" late, ~constant effort because per-fight throughput
90/// grows too). Band 1 = the base `loop_task.loop.<N>` quest; bands 2/3 = the
91/// `loop_task.loop.<N>.<band>` size variants. Thresholds align with the
92/// systems-online wave (skills ch11 / pets ch16) and the milestone cap (ch40).
93fn 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
105/// Balance of currency `id` in the character's wallet (0 when absent).
106fn 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
114/// «Запусти ступень честа» is servable only when the step can be STARTED right
115/// now: no upgrade already running, the next ladder level exists, its chapter
116/// requirement is met, and the wallet covers the full cost. Anything else
117/// would park the single active slot on an hours-long wait.
118fn 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    // Players start at case level 1; a raw 0 (uninitialized) must not resolve
127    // to "upgrade to level 1", whose ladder row has no cost.
128    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
142/// DOABILITY predicate of a pool task: can the player complete it RIGHT NOW by
143/// playing? A task that fails this is skipped by the pool walk — the chain must
144/// never park on an impossible objective (the OVT-2405 hang class, generalized
145/// from the hardcoded skill-slot skip to every task). Tasks 1/2/6 are the
146/// always-doable anchors (chest opens self-fund via kill drops; kills and
147/// waves are the core activity), so the walk always terminates.
148fn 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        // One ability-gacha roll costs 10 SC.
157        3 => skills_open && bal(SC_CURRENCY) >= 10,
158        // Selling needs something unequipped to sell.
159        4 => character_state.inventory.iter().any(|i| !i.is_equipped),
160        // An upgrade is possible when some owned ability has enough shards
161        // (L→L+1 costs L shards).
162        5 => {
163            skills_open
164                && character_state
165                    .all_abilities
166                    .iter()
167                    .any(|a| a.shards_amount >= a.level)
168        }
169        // Gems are only spendable once skills exist (the legacy slot-7 rule).
170        7 => skills_open && bal(GEMS_CURRENCY) >= 100,
171        // Boss: front boss not hopeless, or dungeon bosses available.
172        8 => {
173            let dungeons_open = chapter >= g.navbar_navigation.dungeon_button_unlock_chapter;
174            // The BAL-037 signed curve covers ch0, so no clamp to ch1 is needed
175            // (the old clamp read the ch0 boss as roughly twice its power).
176            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        // One pet-gacha roll costs 1 Beast token.
181        9 => pets_open && bal(BEAST_TOKEN_CURRENCY) >= 1,
182        // Pet upgrade proxy: owns a pet and holds at least one token.
183        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
190/// Band-`band` size variant of core loop slot `n` (`loop_task.loop.<n>.<band>`),
191/// or `None` for band 1 or a slot with no variant — the caller then falls back to
192/// the base `loop_task.loop.<n>`. Only the scalable slots author band-2/3 variants;
193/// the gated skill slots (3/5/7) have none and always use the base quest.
194fn 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
201/// Selects the PROXIMATE (in-time) milestone tier for `type_` ("level"/"stage"):
202/// the tier whose target is the SMALLEST strictly above the player's current
203/// level/chapter, paced once per `frequency` core completions. The old
204/// `last_milestone + 1` fixed-sequence selector decoupled the served tier from
205/// real progress, so it either raced ahead ("Reach Level 28" handed to a level-12
206/// player → blocks the single active slot) or lagged behind ("Clear Stage 1-7" at
207/// stage 6-1 → stale/trivial). Proximate selection always serves the NEXT tier
208/// just ahead — never stale, never far — so the milestone is achievable soon and
209/// "feels great" when hit (user: "milestones are good IF in time"). The tier's
210/// target lives in its `reach_level_<N>` / `reach_chapter_level_<N>` behavior
211/// suffix. Tiers cap at level 50 / chapter 40, so milestones naturally phase out
212/// before the late-game soft walls — no wall-blocking. `quantity` bounds the scan.
213fn 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
256/// Trailing integer of a `loop_task_reach_level_<N>` /
257/// `loop_task_reach_chapter_level_<N>` behavior name — the milestone tier's
258/// target level/chapter. `None` for any other shape (that tier is skipped).
259fn 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
271// ---------------------------------------------------------------------------
272// Native (Vec<OverlordEvent>) loop_tasks logic.
273//
274// `advance_loop` / `prepare_loop` / `on_finish_regular_loop_task`
275// push into a plain `Vec<OverlordEvent>` (same branch order, same RNG draw
276// ---------------------------------------------------------------------------
277
278fn 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
288/// Native port of the loop-task `advance_loop` logic.
289pub 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        // Pre-existing bug (fixed with pool v2): `prepare_loop` scheduled the
313        // blueprint-dungeon insert but `advance_loop` never consumed it — the
314        // Hidden Cove loop task could not be served at all.
315        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    // Pool v2 walk: start after the last served task's pool position and take
365    // the first task whose gate AND doability checks pass. Deterministic (no
366    // RNG), inherently no-repeat (the walk moves forward through the pool),
367    // and hang-free: the always-doable anchors (1/2/6) guarantee termination
368    // for ANY wallet/inventory state — the generalized OVT-2405 guarantee.
369    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        // A task is a candidate only if its content actually exists (deploy
378        // order safety: this Rust can ship before the new pool quests land —
379        // the pool then degrades to the authored subset instead of serving a
380        // code that resolves to no quest and hanging the chain).
381        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; // unreachable with the anchor tasks; defensive
389    };
390
391    // Every pool task serves the size variant for the player's band
392    // (`loop_task.loop.<n>.<band>`), falling back to the base quest when the
393    // variant isn't authored (band 1 and not-yet-banded content).
394    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
408/// Native port of the loop-task `prepare_loop` logic.
409/// Consumes RNG in the IDENTICAL order (gold → cookie → blueprint slot draws).
410pub 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    /// Test config with all pool-relevant gates at explicit chapters.
477    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    /// Drives `advance_loop` and returns the pool task written to
510    /// `loop_tasks.last` — i.e. the task actually assigned. Registers a quest
511    /// code for every pool task so any landing task resolves to a quest.
512    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        // Skills locked, empty wallet/inventory: after task 2 the walk skips
536        // 3 (skills), 4 (nothing to sell), 5 (skills) and lands on anchor 6.
537        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        // Skills open and the wallet covers one gacha roll → task 3 stays.
544        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        // Skills open but 0 SC: serving «Призови скиллы» would hang the chain
553        // — the doability check must skip it exactly like a closed gate.
554        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        // After 6: task 7 needs skills open AND ≥100 gems.
562        let mut rich = state_at(11, 6);
563        give_currency(&mut rich, GEMS_CURRENCY, 150);
564        assert_eq!(assigned_task(&config, &rich), 7);
565        // Without gems the walk skips 7; 8 (boss) is doable here because
566        // dungeons are open at ch20+... at ch11 they are not, and power 0 is
567        // hopeless vs the front curve → skips to 9/10 (no pets/tokens), 11
568        // (no items), 13 (no ladder in test config) → wraps to anchor 1.
569        assert_eq!(assigned_task(&config, &state_at(11, 6)), 1);
570    }
571
572    #[test]
573    fn boss_task_served_when_dungeons_open() {
574        // Dungeon bosses are re-fightable → the boss task is always doable
575        // once dungeons unlock, regardless of front-boss odds.
576        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        // The generalized OVT-2405 guarantee: for ANY previous task and a
583        // fully empty state, the walk terminates on an always-doable anchor.
584        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        // Deploy-order safety: with ONLY the legacy 1-7 content authored, the
603        // walk must never select 8-13 (their codes resolve to no quest) —
604        // e.g. after 7 it lands on an authored, doable task instead.
605        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        // The proximate-milestone selector reads each tier's target from its
633        // reach-level / reach-chapter behavior suffix.
634        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        // Non-reach behaviors have no numeric target → that tier is skipped.
641        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        // Scalable tasks pick a larger size tier as the player advances.
648        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        // Band 1 has no `.band` variant (uses the base quest).
657        let lookups = ContentLookups::default();
658        assert_eq!(band_quest(&lookups, 2, 1), None);
659        assert_eq!(band_quest(&lookups, 2, 2), None); // absent → caller falls back
660    }
661}