1use std::collections::HashMap;
17
18use configs::game_config::GameConfig;
19use essences::character_state::CharacterState;
20use essences::quest::{QuestGroupType, QuestTemplate};
21use uuid::Uuid;
22
23const 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
33pub 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
63fn 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
76fn 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 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
112fn 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 out.sort_by(|a, b| a.base.cmp(&b.base));
158 out
159}
160
161pub 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 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 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
197pub 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
206pub 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 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 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 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 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}