overlord_event_system/mechanics/
quest_board.rs

1//! Daily-board / weekly-set selection.
2//!
3//! The daily board is a rotated subset of the authored daily pool: 8 quests
4//! per day picked by category quotas, ONLY from objectives whose feature is
5//! unlocked for this player (gate rule: never serve a quest the player cannot
6//! complete — the served board is fully doable at any chapter), in the size
7//! variant matching the player's chapter band. Selection is a pure,
8//! deterministic function of (config, player, day index): the monolith calls
9//! it at state load and at the lazy daily reset; there is no stored board.
10//!
11//! Code conventions (mirrored by the content generator):
12//!   `daily.<category>.<objective>[.<band>]`   category ∈ combat|eco|prog|misc|meta
13//!   `weekly.<objective>[.<band>]`
14//! Band-1 quests have no band suffix (same rule as `loop_task.loop.<n>`).
15
16use std::collections::HashMap;
17
18use configs::game_config::GameConfig;
19use essences::character_state::CharacterState;
20use essences::quest::{QuestGroupType, QuestTemplate};
21use uuid::Uuid;
22
23/// Category quotas: 2 combat + 2 economy + 2 progression + 1 misc + 1 meta = 8.
24const BOARD_QUOTAS: &[(&str, usize)] = &[
25    ("combat", 2),
26    ("eco", 2),
27    ("prog", 2),
28    ("misc", 1),
29    ("meta", 1),
30];
31pub const BOARD_SIZE: usize = 8;
32
33/// Chapter at which a quest's OBJECTIVE becomes completable, derived from what
34/// the quest listens to — the objective's events imply the feature it needs.
35/// Derivation (not a per-quest config field) keeps the map self-maintaining:
36/// a new quest on the same events inherits the right gate automatically.
37pub fn objective_gate_chapter(config: &GameConfig, tpl: &QuestTemplate) -> i64 {
38    let g = &config.gatings;
39    let behavior = tpl.progress_behavior.as_deref().unwrap_or("");
40    if behavior.starts_with("raid_dungeon") {
41        return g.navbar_navigation.dungeon_button_unlock_chapter;
42    }
43    if behavior.starts_with("pvp_") {
44        return g.sidebar_navigation.arena_button_unlock_chapter;
45    }
46    let mut gate = 0i64;
47    for ev in &tpl.events_subscribe {
48        let ev_gate = match ev.as_str() {
49            "RaidDungeon" => g.navbar_navigation.dungeon_button_unlock_chapter,
50            "OpenAbilityCase" | "AbilityCaseOpened" | "UpgradedAbilities" | "UpgradeAbility"
51            | "NewAbilities" => g.navbar_navigation.skills_button_unlock_chapter,
52            "OpenPetCase" | "PetCaseOpened" | "UpgradedPets" | "UpgradePet" | "NewPets"
53            | "EquipPet" => g.navbar_navigation.pets_button_unlock_chapter,
54            "AfkRewardClaimed" | "ClaimAfkReward" => g.afk_rewards_button_unlock_chapter,
55            "WatchAd" | "ShowBird" | "BirdShown" => g.daily_boost_button_unlock_chapter,
56            _ => 0,
57        };
58        gate = gate.max(ev_gate);
59    }
60    gate
61}
62
63/// Same band grid as the loop pool (`loop_tasks::band_for_chapter`).
64fn band_for_chapter(chapter: i64) -> i64 {
65    if chapter < 16 {
66        1
67    } else if chapter <= 40 {
68        2
69    } else if chapter <= 80 {
70        3
71    } else {
72        4
73    }
74}
75
76/// Splits `daily.<cat>.<obj>[.<band>]` → (category, objective base code, band).
77/// `weekly.<obj>[.<band>]` → (category="", base, band).
78fn parse_code(code: &str) -> Option<(String, String, i64)> {
79    let (prefix, rest) = code.split_once('.')?;
80    let (cat, obj_part) = match prefix {
81        "daily" => {
82            let (cat, obj) = rest.split_once('.')?;
83            (cat.to_string(), obj)
84        }
85        "weekly" => (String::new(), rest),
86        _ => return None,
87    };
88    // A trailing numeric segment is the band; otherwise band 1. The objective
89    // key keeps the category prefix (`combat.kill`) so objectives with the
90    // same name in different categories can't collide in the variant map.
91    let (obj, band) = match obj_part.rsplit_once('.') {
92        Some((base, last)) if last.parse::<i64>().is_ok() => {
93            (base.to_string(), last.parse::<i64>().unwrap())
94        }
95        _ => (obj_part.to_string(), 1),
96    };
97    let key = if cat.is_empty() {
98        obj
99    } else {
100        format!("{cat}.{obj}")
101    };
102    Some((cat, key, band))
103}
104
105struct Candidate<'a> {
106    tpl: &'a QuestTemplate,
107    category: String,
108    base: String,
109    band: i64,
110}
111
112/// Gate-open candidates of `group`, one per objective: the best authored band
113/// variant ≤ the player's band (falls back down when a higher band isn't
114/// authored — same fallback rule as the loop pool's `band_quest`).
115fn open_candidates<'a>(
116    config: &'a GameConfig,
117    character_state: &CharacterState,
118    group: QuestGroupType,
119    prefix: &str,
120) -> Vec<Candidate<'a>> {
121    let chapter = character_state.character.current_chapter_level;
122    let player_band = band_for_chapter(chapter);
123    let mut best: HashMap<String, Candidate<'a>> = HashMap::new();
124    for tpl in config.quests.iter() {
125        if tpl.quest_group_type != group {
126            continue;
127        }
128        let Some(code) = tpl.code.as_deref() else {
129            continue;
130        };
131        if !code.starts_with(prefix) {
132            continue;
133        }
134        let Some((category, base, band)) = parse_code(code) else {
135            continue;
136        };
137        if band > player_band || objective_gate_chapter(config, tpl) > chapter {
138            continue;
139        }
140        match best.get(&base) {
141            Some(cur) if cur.band >= band => {}
142            _ => {
143                best.insert(
144                    base.clone(),
145                    Candidate {
146                        tpl,
147                        category,
148                        base,
149                        band,
150                    },
151                );
152            }
153        }
154    }
155    let mut out: Vec<Candidate<'a>> = best.into_values().collect();
156    // Deterministic order for rotation: by objective base code.
157    out.sort_by(|a, b| a.base.cmp(&b.base));
158    out
159}
160
161/// Today's daily board: quota-per-category rotation over the open pool,
162/// deterministic in (player band/gates, `day_seed`). Shortfall in one
163/// category is filled from the remaining open pool so the board reaches
164/// [`BOARD_SIZE`] whenever enough objectives are open.
165pub fn daily_board(
166    config: &GameConfig,
167    character_state: &CharacterState,
168    day_seed: i64,
169) -> Vec<Uuid> {
170    let pool = open_candidates(config, character_state, QuestGroupType::Daily, "daily.");
171    let mut picked: Vec<&Candidate> = Vec::with_capacity(BOARD_SIZE);
172    for (cat, quota) in BOARD_QUOTAS {
173        let members: Vec<&Candidate> = pool.iter().filter(|c| c.category == *cat).collect();
174        if members.is_empty() {
175            continue;
176        }
177        // Rotate the category window by the day: different objectives on
178        // different days, all of them over a full cycle.
179        let start = (day_seed.rem_euclid(members.len() as i64)) as usize;
180        for k in 0..members.len().min(*quota) {
181            picked.push(members[(start + k) % members.len()]);
182        }
183    }
184    // Fill up to BOARD_SIZE from open objectives not yet on the board.
185    for c in &pool {
186        if picked.len() >= BOARD_SIZE {
187            break;
188        }
189        if !picked.iter().any(|p| p.base == c.base) {
190            picked.push(c);
191        }
192    }
193    picked.truncate(BOARD_SIZE);
194    picked.into_iter().map(|c| c.tpl.id).collect()
195}
196
197/// This week's weekly set: every gate-open objective in the band-matched
198/// variant (no rotation — the weekly pool IS the set).
199pub fn weekly_set(config: &GameConfig, character_state: &CharacterState) -> Vec<Uuid> {
200    open_candidates(config, character_state, QuestGroupType::Weekly, "weekly.")
201        .into_iter()
202        .map(|c| c.tpl.id)
203        .collect()
204}
205
206/// Whether `tpl` participates in board/set selection at all. Templates
207/// WITHOUT a `daily.`/`weekly.` code keep the legacy always-served behavior —
208/// deploy-order safety: until the coded content lands, boards fall back to
209/// the current static sets.
210pub fn is_board_managed(tpl: &QuestTemplate) -> bool {
211    matches!(
212        (&tpl.quest_group_type, tpl.code.as_deref()),
213        (QuestGroupType::Daily, Some(c)) if c.starts_with("daily.")
214    ) || matches!(
215        (&tpl.quest_group_type, tpl.code.as_deref()),
216        (QuestGroupType::Weekly, Some(c)) if c.starts_with("weekly.")
217    )
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use configs::tests_game_config::generate_game_config_for_tests;
224
225    fn tpl(group: QuestGroupType, code: &str, events: &[&str], behavior: &str) -> QuestTemplate {
226        QuestTemplate {
227            id: Uuid::new_v4(),
228            title: Default::default(),
229            description: Default::default(),
230            progress_behavior: Some(behavior.to_string()),
231            progress_target: 1,
232            quest_group_type: group,
233            bundle_id: None,
234            progression_points: 10,
235            starting: false,
236            next_quest_ids: vec![],
237            events_subscribe: events.iter().map(|s| s.to_string()).collect(),
238            additional_quests_behavior: None,
239            progress_if_inactive: true,
240            screen_reference: None,
241            code: Some(code.to_string()),
242        }
243    }
244
245    fn config_with_dailies() -> GameConfig {
246        let mut config = generate_game_config_for_tests();
247        config
248            .gatings
249            .navbar_navigation
250            .skills_button_unlock_chapter = 11;
251        config.gatings.navbar_navigation.pets_button_unlock_chapter = 16;
252        config
253            .gatings
254            .navbar_navigation
255            .dungeon_button_unlock_chapter = 20;
256        config
257            .gatings
258            .sidebar_navigation
259            .arena_button_unlock_chapter = 23;
260        config.gatings.afk_rewards_button_unlock_chapter = 8;
261        let d = QuestGroupType::Daily;
262        config.quests = vec![
263            tpl(d, "daily.combat.kill", &["EntityDeath"], "increment_one"),
264            tpl(d, "daily.combat.kill.2", &["EntityDeath"], "increment_one"),
265            tpl(d, "daily.combat.waves", &["WaveCleared"], "increment_one"),
266            tpl(d, "daily.combat.boss", &["EndFight"], "boss_win"),
267            tpl(d, "daily.combat.arena", &["EndFight"], "pvp_win"),
268            tpl(
269                d,
270                "daily.combat.dungeon1",
271                &["EndFight", "RaidDungeon"],
272                "raid_dungeon_1",
273            ),
274            tpl(d, "daily.eco.open", &["OpenItemCase"], "increment_one"),
275            tpl(d, "daily.eco.sell", &["SellItem"], "increment_one"),
276            tpl(
277                d,
278                "daily.eco.summon_skills",
279                &["AbilityCaseOpened"],
280                "increment_by_batch_size",
281            ),
282            tpl(
283                d,
284                "daily.prog.level",
285                &["NewCharacterLevel"],
286                "increment_one",
287            ),
288            tpl(d, "daily.prog.equip", &["PlayerEquipItem"], "increment_one"),
289            tpl(
290                d,
291                "daily.prog.case_step",
292                &["ItemCaseUpgraded"],
293                "increment_one",
294            ),
295            tpl(d, "daily.misc.login", &["PrepareFight"], "increment_one"),
296            tpl(d, "daily.misc.afk", &["AfkRewardClaimed"], "increment_one"),
297            tpl(
298                d,
299                "daily.meta.complete7",
300                &["QuestCompleted"],
301                "complete_daily_quest",
302            ),
303        ];
304        config
305    }
306
307    fn state_at(chapter: i64) -> CharacterState {
308        let mut s = CharacterState::default();
309        s.character.current_chapter_level = chapter;
310        s
311    }
312
313    #[test]
314    fn early_board_contains_only_gate_open_objectives() {
315        // ch3: dungeons(20)/arena(23)/skills(11)/afk(8) are locked — the board
316        // must consist solely of gate-free objectives and still fill quotas
317        // from them (this is the live-bug fix: today's static board hands a
318        // ch3 player dungeon/arena/pet quests it cannot complete).
319        let config = config_with_dailies();
320        let state = state_at(3);
321        let board = daily_board(&config, &state, 0);
322        let locked: Vec<&str> = board
323            .iter()
324            .filter_map(|id| config.quests.iter().find(|q| q.id == *id))
325            .filter(|q| objective_gate_chapter(&config, q) > 3)
326            .filter_map(|q| q.code.as_deref())
327            .collect();
328        assert!(locked.is_empty(), "locked objectives on board: {locked:?}");
329        assert!(!board.is_empty());
330    }
331
332    #[test]
333    fn band_variant_matches_player_band() {
334        let config = config_with_dailies();
335        // ch25 = band 2 → the `kill` objective must resolve to its `.2`
336        // variant in the candidate pool (whatever the day's rotation later
337        // picks); a band-1 player resolves the same objective to the base.
338        let cand_b2 = open_candidates(&config, &state_at(25), QuestGroupType::Daily, "daily.");
339        let kill_b2 = cand_b2.iter().find(|c| c.base == "combat.kill").unwrap();
340        assert_eq!(kill_b2.band, 2);
341        assert_eq!(kill_b2.tpl.code.as_deref(), Some("daily.combat.kill.2"));
342
343        let cand_b1 = open_candidates(&config, &state_at(3), QuestGroupType::Daily, "daily.");
344        let kill_b1 = cand_b1.iter().find(|c| c.base == "combat.kill").unwrap();
345        assert_eq!(kill_b1.band, 1);
346    }
347
348    #[test]
349    fn rotation_changes_the_board_between_days() {
350        let config = config_with_dailies();
351        let state = state_at(25);
352        let day0 = daily_board(&config, &state, 0);
353        let day1 = daily_board(&config, &state, 1);
354        assert_ne!(day0, day1, "rotation must vary the board across days");
355        // Deterministic: same day → same board.
356        assert_eq!(day0, daily_board(&config, &state, 0));
357    }
358
359    #[test]
360    fn board_is_capped_and_meta_is_always_served() {
361        let config = config_with_dailies();
362        let board = daily_board(&config, &state_at(25), 3);
363        assert!(board.len() <= BOARD_SIZE);
364        let meta = config
365            .quests
366            .iter()
367            .find(|q| q.code.as_deref() == Some("daily.meta.complete7"))
368            .unwrap()
369            .id;
370        assert!(board.contains(&meta), "meta quest must be on every board");
371    }
372
373    #[test]
374    fn uncoded_templates_stay_legacy_served() {
375        // Deploy-order safety: a Daily template without a `daily.` code is not
376        // board-managed (the static legacy set keeps serving it).
377        let legacy = tpl(
378            QuestGroupType::Daily,
379            "something_else",
380            &[],
381            "increment_one",
382        );
383        assert!(!is_board_managed(&legacy));
384        let coded = tpl(
385            QuestGroupType::Daily,
386            "daily.misc.login",
387            &[],
388            "increment_one",
389        );
390        assert!(is_board_managed(&coded));
391    }
392}