diff --git a/src/router.rs b/src/router.rs index 6d11397..8324270 100644 --- a/src/router.rs +++ b/src/router.rs @@ -7,6 +7,7 @@ pub mod user; pub mod purchase; pub mod tutorial; pub mod mission; +pub mod beginner_mission; pub mod home; pub mod lottery; pub mod friend; diff --git a/src/router/beginner_mission.rs b/src/router/beginner_mission.rs new file mode 100644 index 0000000..8093c91 --- /dev/null +++ b/src/router/beginner_mission.rs @@ -0,0 +1,315 @@ +use jzon::{array, object, JsonValue}; +use lazy_static::lazy_static; + +use super::{databases, items}; +use databases::csv::{self, Region}; + +lazy_static! { + static ref CELLS: JsonValue = csv::table(Region::Jp, "beginner_mission"); + static ref SETTINGS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward_setting"); + static ref REWARDS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward"); + static ref CARD_LEVELS: JsonValue = csv::table(Region::Jp, "card_level"); + static ref SKILL_LEVELS: JsonValue = csv::table(Region::Jp, "card_skill_level"); +} + +// JP /api/mission + /api/mission/receive captures, 2024-03-31: +// all sheets accumulate progress; only reached sheets can be claimed. Line +// rewards are milestones in the number of completed lines, not named lines. +const LINES: [[i64; 3]; 8] = [ + [1, 2, 3], [4, 5, 6], [7, 8, 9], [1, 4, 7], + [2, 5, 8], [3, 6, 9], [1, 5, 9], [3, 5, 7], +]; + +pub fn contains(id: i64) -> bool { + CELLS.members().any(|c| c["masterMissionId"].as_i64() == Some(id)) +} + +pub fn level(id: i64) -> i64 { + CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id)) + .and_then(|c| c["level"].as_i64()).unwrap_or(0) +} + +pub fn is_counter(id: i64) -> bool { + contains(id) && matches!(databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64(), Some(5 | 6 | 53 | 62 | 63)) +} + +pub fn ensure(missions: &mut JsonValue) { + for cell in CELLS.members() { + let id = cell["masterMissionId"].as_i64().unwrap(); + let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap(); + if let Some(mission) = missions.members_mut().find(|m| m["master_mission_id"].as_i64() == Some(id)) { + // The old login hook kept incrementing an already completed cell. + // The client subtracts one to animate an unclaimed completion, so + // over-target progress would incorrectly make its "before" sheet clear. + let progress = if mission["status"].as_i64().unwrap_or(1) >= 2 { target } + else { mission["progress"].as_i64().unwrap_or(0).clamp(0, target) }; + mission["progress"] = progress.into(); + continue; + } + missions.push(object! { + master_mission_id: id, status: 1, progress: 0, + expire_date_time: 0, not_visible: 0 + }).unwrap(); + } +} + +pub fn set_progress(id: i64, progress: i64, missions: &mut JsonValue) -> bool { + ensure(missions); + let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap(); + for mission in missions.members_mut() { + if mission["master_mission_id"].as_i64() != Some(id) { continue; } + if mission["status"].as_i64().unwrap_or(1) >= 2 { return false; } + let progress = progress.max(mission["progress"].as_i64().unwrap_or(0)).min(target); + mission["progress"] = progress.into(); + mission["status"] = if progress >= target { 2 } else { 1 }.into(); + return progress >= target; + } + false +} + +pub fn advance(condition: i64, value: Option, amount: i64, missions: &mut JsonValue) -> JsonValue { + ensure(missions); + let mut cleared = array![]; + for cell in CELLS.members() { + let id = cell["masterMissionId"].as_i64().unwrap(); + let mst = &databases::MISSION_LIST[id.to_string()]; + if mst["conditionType"].as_i64() != Some(condition) { continue; } + if let Some(value) = value { + if mst["conditionValues"][0].as_i64() != Some(value) { continue; } + } + let old = items::get_mission_status(id, missions)["progress"].as_i64().unwrap_or(0); + if set_progress(id, old + amount, missions) { cleared.push(id).unwrap(); } + } + cleared +} + +pub fn refresh(user: &JsonValue, missions: &mut JsonValue) -> JsonValue { + ensure(missions); + let mut cleared = array![]; + for cell in CELLS.members() { + let id = cell["masterMissionId"].as_i64().unwrap(); + let mst = &databases::MISSION_LIST[id.to_string()]; + let value = mst["conditionValues"][0].as_i64().unwrap_or(0); + let progress = match mst["conditionType"].as_i64().unwrap() { + 14 | 64 => { + let skill = mst["conditionType"] == 64; + user["card_list"].members().filter(|card| { + let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0)); + let curve = if skill { databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"].as_i64() } + else { master["masterCardLevelId"].as_i64() }; + let levels = if skill { &*SKILL_LEVELS } else { &*CARD_LEVELS }; + levels.members().any(|l| l["id"].as_i64() == curve && l["level"].as_i64() == Some(value) + && card[if skill { "skill_exp" } else { "exp" }].as_i64().unwrap_or(0) >= l["exp"].as_i64().unwrap()) + }).count() as i64 + } + 17 => user["card_list"].members().filter(|c| !c["evolve"].is_empty()).count() as i64, + 58 => user["character_list"].members().filter_map(|c| c["exp"].as_i64()).max().unwrap_or(0), + 30 | 60 => { + let mut best = 0; + for deck in user["deck_list"].members() { + let mut types = [0i64; 3]; + let mut points = [0i64; 3]; + for slot in deck["main_card_ids"].members() { + let Some(card) = user["card_list"].members().find(|c| c["id"] == *slot) else { continue; }; + let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0)); + if let Some(t) = master["type"].as_usize().filter(|t| (1..=3).contains(t)) { types[t-1] += 1; } + let level = CARD_LEVELS.members().filter(|l| l["id"] == master["masterCardLevelId"] + && l["exp"].as_i64().unwrap() <= card["exp"].as_i64().unwrap_or(0)) + .max_by_key(|l| l["level"].as_i64()); + if let Some(l) = level { + for (i, name) in ["smile", "pure", "cool"].iter().enumerate() { + points[i] += master[*name].as_i64().unwrap_or(0) * l[format!("{}Ratio",name)].as_i64().unwrap_or(0) / 10000; + } + } + } + let p = if mst["conditionType"] == 30 { + let needed = mst["conditionValues"][1].as_i64().unwrap(); + (types.iter().any(|n| *n >= needed)) as i64 + } else { *points.iter().max().unwrap() }; + best = best.max(p); + } + best + } + _ => continue, + }; + if set_progress(id, progress, missions) { cleared.push(id).unwrap(); } + } + cleared +} + +pub fn refresh_account(user: &JsonValue, missions: &mut JsonValue) -> JsonValue { + let mut cleared = refresh(user, missions); + // Existing accounts may have prepared transfer credentials before missions + // were enabled. Merely requesting a code leaves an empty password. + if let Some(uid) = user["user"]["id"].as_i64() { + if super::userdata::has_transfer_password(uid) { + for id in advance(25, None, 1, missions).members() { cleared.push(id.clone()).unwrap(); } + } + } + cleared +} + +fn sheet_cells(group: i64, level: i64) -> impl Iterator { + CELLS.members().filter(move |c| c["id"].as_i64() == Some(group) && c["level"].as_i64() == Some(level)) +} + +fn claimed(group: i64, level: i64, missions: &JsonValue) -> Vec { + sheet_cells(group, level).filter(|c| { + items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3 + }).filter_map(|c| c["number"].as_i64()).collect() +} + +pub fn claimable(id: i64, missions: &JsonValue) -> bool { + let Some(cell) = CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id)) else { return false; }; + CELLS.members().filter(|c| c["id"] == cell["id"] && c["level"].as_i64() < cell["level"].as_i64()) + .all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3) +} + +pub fn home_status(missions: &JsonValue) -> (usize, bool) { + let all = CELLS.members().all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3); + let count = CELLS.members().filter(|c| { + let id = c["masterMissionId"].as_i64().unwrap(); + items::get_mission_status(id, missions)["status"] == 2 && claimable(id, missions) + }).count(); + (count, all) +} + +pub fn new_rewards(before: &JsonValue, after: &JsonValue) -> Vec { + let mut settings: Vec<_> = SETTINGS.members().collect(); + // Captures return line milestones before the full-sheet reward. + settings.sort_by_key(|s| (s["masterBeginnerMissionId"].as_i64(), s["level"].as_i64(), + if s["number"] == 0 { 9 } else { s["number"].as_i64().unwrap() })); + let mut result = Vec::new(); + for setting in settings { + let group = setting["masterBeginnerMissionId"].as_i64().unwrap(); + let level = setting["level"].as_i64().unwrap(); + let number = setting["number"].as_i64().unwrap(); + // JP cross-sheet claims award bonuses only for sheets unlocked before + // the request (recorded claims 8 and 11 in the replay fixture). + let first = sheet_cells(group, level).next().unwrap()["masterMissionId"].as_i64().unwrap(); + if !claimable(first, before) { continue; } + let achieved = |missions: &JsonValue| { + let cells = claimed(group, level, missions); + if number == 0 { cells.len() == 9 } + else { LINES.iter().filter(|line| line.iter().all(|n| cells.contains(n))).count() >= number as usize } + }; + if !achieved(before) && achieved(after) { + result.extend(REWARDS.members().filter(|r| r["id"] == setting["masterBeginnerMissionRewardId"]).cloned()); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_inventory_drives_training_bond_and_deck_goals() { + let mut user = object! { card_list: [], character_list: [{exp: 30}], deck_list: [] }; + let master = super::super::custom_card::card_info(10010001); + let exp = CARD_LEVELS.members().find(|l| l["id"] == master["masterCardLevelId"] && l["level"] == 30).unwrap()["exp"].clone(); + let curve = &databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"]; + let skill_exp = SKILL_LEVELS.members().find(|l| l["id"] == *curve && l["level"] == 2).unwrap()["exp"].clone(); + for id in 1..=5 { + user["card_list"].push(object! {id: id, master_card_id: 10010001, + exp: exp.clone(), skill_exp: skill_exp.clone(), evolve: [{type: 2, count: 1}]}).unwrap(); + } + user["deck_list"].push(object! {main_card_ids: [1,2,3,4,5,0,0,0,0]}).unwrap(); + let mut missions = array![]; + let cleared = refresh(&user, &mut missions); + for id in [1614001,1614002,1614003,1664001,1664002,1617001,1658001,1658002,1658003,1630001] { + assert!(cleared.contains(id), "mission {}", id); + } + assert!(refresh(&user, &mut missions).is_empty()); + assert_eq!(items::get_mission_status(1605001, &missions)["progress"], 0); + } + + #[test] + fn counters_match_values_and_home_counts_only_unlocked_sheet() { + let mut missions = array![]; + assert!(advance(63, Some(1110001), 20, &mut missions).is_empty()); + assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663001]); + assert_eq!(items::get_mission_status(1663002, &missions)["progress"], 10); + assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663002]); + assert!(advance(6, Some(1), 1, &mut missions).is_empty()); + assert_eq!(advance(6, Some(2), 1, &mut missions), array![1606001]); + advance(53, None, 3, &mut missions); + assert_eq!(home_status(&missions), (2, false)); + for row in missions.members_mut() { row["status"] = 3.into(); } + assert_eq!(home_status(&missions), (0, true)); + assert!(advance(53, None, 1, &mut missions).is_empty()); + } + + #[test] + fn all_recorded_jp_beginner_claims_match() { + let cases = jzon::parse(include_str!("beginner_mission_claims.json")).unwrap(); + assert_eq!(cases.len(), 15); + for (i, case) in cases.members().enumerate() { + let mut before = array![]; + ensure(&mut before); + for row in before.members_mut() { + if case["before"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); } + } + let mut after = before.clone(); + for row in after.members_mut() { + if case["claim"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); } + } + let expected: Vec<_> = case["bonus"].members().map(|x| x.as_i64().unwrap()).collect(); + let actual: Vec<_> = new_rewards(&before, &after).iter().map(|x| x["amount"].as_i64().unwrap()).collect(); + assert_eq!(actual, expected, "JP claim {}", i); + } + } + + #[test] + fn progress_is_capped_and_claims_are_not_reset() { + let mut m = array![]; + advance(5, None, 12, &mut m); + assert_eq!(items::get_mission_status(1605001, &m)["progress"], 1); + assert_eq!(items::get_mission_status(1605002, &m)["progress"], 10); + assert_eq!(items::get_mission_status(1605003, &m)["progress"], 12); + assert!(!claimable(1605002, &m)); + for row in m.members_mut() { if row["master_mission_id"] == 1605001 { row["status"] = 3.into(); } } + advance(5, None, 1, &mut m); + assert_eq!(items::get_mission_status(1605001, &m)["status"], 3); + for row in m.members_mut() { + if row["master_mission_id"] == 1653001 { + row["status"] = 2.into(); + row["progress"] = 50.into(); + } + } + ensure(&mut m); + assert_eq!(items::get_mission_status(1653001, &m)["progress"], 1); + } + + #[test] + fn captured_first_sheet_claim_awards_one_line() { + let mut before = array![]; + ensure(&mut before); + let mut after = before.clone(); + for row in after.members_mut() { + if [1653001,1605001,1663001,1666001,1658001].contains(&row["master_mission_id"].as_i64().unwrap()) { + row["status"] = 3.into(); + } + } + let r = new_rewards(&before, &after); + assert_eq!(r.len(), 1); + assert_eq!(r[0]["amount"], 50); + assert!(new_rewards(&after, &after).is_empty()); + } + + #[test] + fn full_sheet_gives_eight_lines_and_sheet_once() { + let mut before = array![]; + ensure(&mut before); + let mut after = before.clone(); + for row in after.members_mut() { + if sheet_cells(1,1).any(|c| c["masterMissionId"] == row["master_mission_id"]) { row["status"] = 3.into(); } + } + let r = new_rewards(&before, &after); + assert_eq!(r.len(), 9); + assert_eq!(r.iter().map(|r| r["amount"].as_i64().unwrap()).sum::(), 1000); + assert!(claimable(1605002, &after)); + assert!(!home_status(&after).1); + } +} diff --git a/src/router/beginner_mission_claims.json b/src/router/beginner_mission_claims.json new file mode 100644 index 0000000..d8461da --- /dev/null +++ b/src/router/beginner_mission_claims.json @@ -0,0 +1,314 @@ +[ + { + "before": [], + "claim": [ + 1653001 + ], + "bonus": [] + }, + { + "before": [ + 1653001, + 1660001, + 1666001 + ], + "claim": [ + 1605001, + 1663001, + 1614001, + 1658001, + 1661001 + ], + "bonus": [ + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "before": [], + "claim": [ + 1653001 + ], + "bonus": [] + }, + { + "before": [], + "claim": [ + 1653001 + ], + "bonus": [] + }, + { + "before": [], + "claim": [ + 1653001 + ], + "bonus": [] + }, + { + "before": [ + 1653001 + ], + "claim": [ + 1605001, + 1663001, + 1660001, + 1614001 + ], + "bonus": [ + 50 + ] + }, + { + "before": [], + "claim": [ + 1653001, + 1605001, + 1663001, + 1666001, + 1658001 + ], + "bonus": [ + 50 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1663001, + 1666001, + 1658001 + ], + "claim": [ + 1614001 + ], + "bonus": [] + }, + { + "before": [ + 1653001, + 1605001, + 1663001, + 1614001, + 1666001, + 1658001 + ], + "claim": [ + 1662001, + 1660001, + 1661001, + 1653002, + 1658002, + 1630001, + 1663002 + ], + "bonus": [ + 50, + 50, + 50, + 50, + 50, + 100, + 100, + 500 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1658002, + 1630001, + 1663002 + ], + "claim": [ + 1665001 + ], + "bonus": [ + 50 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1665001, + 1658002, + 1630001, + 1663002 + ], + "claim": [ + 1664001, + 1614002 + ], + "bonus": [ + 50 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1665001, + 1664001, + 1658002, + 1630001, + 1614002, + 1663002 + ], + "claim": [ + 1605002, + 1606001, + 1653003, + 1660002, + 1617001, + 1658003, + 1625001 + ], + "bonus": [ + 50, + 50, + 50, + 100, + 100, + 500 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1605002, + 1665001, + 1664001, + 1658002, + 1630001, + 1606001, + 1614002, + 1663002, + 1653003, + 1660002, + 1617001, + 1658003, + 1625001 + ], + "claim": [ + 1626001 + ], + "bonus": [ + 50, + 50 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1605002, + 1665001, + 1664001, + 1658002, + 1630001, + 1606001, + 1614002, + 1663002, + 1653003, + 1660002, + 1617001, + 1658003, + 1625001, + 1626001 + ], + "claim": [ + 1614003, + 1664002 + ], + "bonus": [ + 50, + 50, + 50 + ] + }, + { + "before": [ + 1653001, + 1605001, + 1662001, + 1663001, + 1660001, + 1614001, + 1666001, + 1658001, + 1661001, + 1653002, + 1605002, + 1665001, + 1664001, + 1658002, + 1630001, + 1606001, + 1614002, + 1663002, + 1653003, + 1660002, + 1617001, + 1658003, + 1625001, + 1614003, + 1664002, + 1626001 + ], + "claim": [ + 1605003 + ], + "bonus": [ + 100, + 100, + 500 + ] + } +] diff --git a/src/router/card.rs b/src/router/card.rs index 1cc8bab..73da543 100644 --- a/src/router/card.rs +++ b/src/router/card.rs @@ -134,6 +134,11 @@ async fn reinforce(Session { key, body }: Session) -> impl Responder { let mut clear_mission_ids = array![]; let card = do_reinforce(&mut user, &body, "exp", 1, false, &mut array![], &mut array![], &mut clear_mission_ids); + let mut missions = userdata::get_acc_missions(&key); + for id in super::beginner_mission::refresh(&user, &mut missions).members() { + clear_mission_ids.push(id.clone()).unwrap(); + } + userdata::save_acc_missions(&key, missions); userdata::save_acc(&key, user.clone()); @@ -150,6 +155,11 @@ async fn skill_reinforce(Session { key, body }: Session) -> impl Responder { let mut clear_mission_ids = array![]; let card = do_reinforce(&mut user, &body, "skill_exp", 10, false, &mut array![], &mut array![], &mut clear_mission_ids); + let mut missions = userdata::get_acc_missions(&key); + for id in super::beginner_mission::refresh(&user, &mut missions).members() { + clear_mission_ids.push(id.clone()).unwrap(); + } + userdata::save_acc_missions(&key, missions); userdata::save_acc(&key, user.clone()); @@ -168,6 +178,7 @@ async fn evolve(Session { key, body }: Session) -> impl Responder { let mut clear_mission_ids = array![]; let card = do_reinforce(&mut user, &body, "", 0, true, &mut missions, &mut chats, &mut clear_mission_ids); + for id in super::beginner_mission::refresh(&user, &mut missions).members() { clear_mission_ids.push(id.clone()).unwrap(); } userdata::save_acc(&key, user.clone()); userdata::save_acc_chats(&key, chats); diff --git a/src/router/gree.rs b/src/router/gree.rs index 63c3ae4..708f95d 100644 --- a/src/router/gree.rs +++ b/src/router/gree.rs @@ -324,6 +324,11 @@ async fn migration_password_register(req: HttpRequest, body: String) -> impl Res let pass = decrypt_transfer_password(&body["migration_password"].to_string()); userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &pass); + if !pass.is_empty() { + let mut missions = userdata::get_acc_missions(&uid); + super::beginner_mission::advance(25, None, 1, &mut missions); + userdata::save_acc_missions(&uid, missions); + } let resp = object!{ result: "OK" diff --git a/src/router/home.rs b/src/router/home.rs index 41cc38f..e2c3c2c 100644 --- a/src/router/home.rs +++ b/src/router/home.rs @@ -23,6 +23,10 @@ async fn preset(Session { key, body }: Session) -> impl Responder { } userdata::save_acc_home(&key, user); + let mut missions = userdata::get_acc_missions(&key); + super::beginner_mission::advance(61, None, 1, &mut missions); + userdata::save_acc_missions(&key, missions); + Api(Some(array![])) } @@ -89,12 +93,13 @@ async fn home(Login(key): Login) -> impl Responder { check_gifts(&mut user); let mut user_missions = userdata::get_acc_missions(&key); - let clear = items::completed_daily_mission(1253003, &mut user_missions); + let mut clear = super::beginner_mission::refresh_account(&userdata::get_acc(&key), &mut user_missions); + for id in items::completed_daily_mission(1253003, &mut user_missions).members() { + clear.push(id.clone()).unwrap(); + } userdata::save_acc_home(&key, user.clone()); user["clear_mission_ids"] = clear; - if !user["clear_mission_ids"].is_empty() { - userdata::save_acc_missions(&key, user_missions.clone()); - } + userdata::save_acc_missions(&key, user_missions.clone()); let daily_missions = array![1224003, 1253003, 1273009, 1273010, 1273011, 1273012]; @@ -128,8 +133,9 @@ async fn home(Login(key): Login) -> impl Responder { let new_announcement = crate::database::announcements::latest_published_at() > seen_at; user["home"]["new_announcement_flag"] = (new_announcement as i32).into(); - //todo - user["home"]["beginner_mission_complete"] = 1.into(); + let (ready, complete) = super::beginner_mission::home_status(&user_missions); + user["home"]["clear_beginner_mission_count"] = ready.into(); + user["home"]["beginner_mission_complete"] = (complete as i32).into(); Api(Some(user)) } diff --git a/src/router/items.rs b/src/router/items.rs index 92c9c56..5ef1c2e 100644 --- a/src/router/items.rs +++ b/src/router/items.rs @@ -545,8 +545,8 @@ pub fn completed_daily_mission(id: i64, missions: &mut JsonValue) -> JsonValue { let mut rv = array![]; if id == 1253003 { rv = advance_variable_mission(1153001, 1153019, 1, missions); - if update_mission_status(1653001, 0, true, false, 1, missions).is_some() { - rv.push(1653001).unwrap(); + for id in super::beginner_mission::advance(53, None, 1, missions).members() { + rv.push(id.clone()).unwrap(); } } for mission in missions.members_mut() { diff --git a/src/router/live.rs b/src/router/live.rs index 0f5cd09..54da000 100644 --- a/src/router/live.rs +++ b/src/router/live.rs @@ -727,6 +727,17 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool //1273009, 1273010, 1273011, 1273012 let mut cleared_missions = items::advance_variable_mission(1105001, 1105017, 1, &mut user_missions); + for id in super::beginner_mission::advance(5, None, 1, &mut user_missions).members() { + cleared_missions.push(id.clone()).unwrap(); + } + if !skipped { + let started = get_started_live(key, body).unwrap_or(JsonValue::Null); + if started["is_omakase"] == 1 { + for id in super::beginner_mission::advance(62, None, 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); } + } + let boost = started["live_boost"].as_i64().or(body["live_boost"].as_i64()).unwrap_or(0); + for id in super::beginner_mission::advance(6, Some(boost), 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); } + } if body["master_live_id"].to_string().len() > 1 { let id = body["master_live_id"].to_string().split("").collect::>()[2].parse::().unwrap_or(0); if (1..=4).contains(&id) { @@ -822,6 +833,7 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool let deck_slot = get_end_live_deck_id(&key, &body).unwrap_or(body["deck_slot"].as_i32().unwrap_or(user["user"]["main_deck_slot"].as_i32().unwrap())); let characters = get_live_character_list(lp_used, deck_slot, &mut user, &mut user_missions, &mut cleared_missions, &mut chats); + for id in super::beginner_mission::refresh(&user, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); } userdata::save_acc(&key, user.clone()); userdata::save_acc_home(&key, user2.clone()); diff --git a/src/router/lottery.rs b/src/router/lottery.rs index 6c21fe1..446314d 100644 --- a/src/router/lottery.rs +++ b/src/router/lottery.rs @@ -390,6 +390,9 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl } add_draw_count(&mut user, lottery_id, price_number); + for id in super::beginner_mission::advance(63, Some(lottery_id), price["count"].as_i64().unwrap_or(0), &mut missions).members() { + cleared_missions.push(id.clone()).unwrap(); + } let mut new_count = get_draw_count(&user, lottery_id, price_number); if is_stepup(lottery_id) && price_number == 1 { new_count += 1; diff --git a/src/router/mission.rs b/src/router/mission.rs index e3f1f2d..db16501 100644 --- a/src/router/mission.rs +++ b/src/router/mission.rs @@ -12,6 +12,50 @@ pub fn routes(cfg: &mut web::ServiceConfig) { ); } +#[cfg(test)] +mod tests { + use super::*; + + #[actix_web::test] + async fn beginner_claims_persist_rewards_and_reject_repeats() { + let _test = crate::runtime::lock_test_data_path(); + let (uid, key) = userdata::starter::create("Beginner test").unwrap(); + let request = || actix_web::test::TestRequest::default() + .insert_header(("aoharu-user-id", uid.to_string())).to_http_request(); + let first = [1653001,1605001,1662001,1663001,1660001,1614001,1666001,1658001,1661001]; + let mut missions = userdata::get_acc_missions(&key); + for id in first { + super::super::beginner_mission::set_progress(id, i64::MAX, &mut missions); + } + super::super::beginner_mission::set_progress(1653002, 2, &mut missions); + userdata::save_acc_missions(&key, missions); + let blocked = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002]} }).await.0.unwrap(); + assert!(blocked["reward_list"].is_empty()); + let gem_before = userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap(); + let mut ids: Vec = first.into(); + ids.push(first[0]); + let body = object! {master_mission_ids: ids}; + let response = receive(request(), Session { key: key.clone(), body: body.clone() }).await.0.unwrap(); + assert_eq!(response["mission_list"].len(), 9); + assert_eq!(response["reward_list"].len(), 18); + let bonuses: i64 = response["reward_list"].members().filter(|r| r["type"] == 1) + .map(|r| r["amount"].as_i64().unwrap()).sum(); + assert_eq!(bonuses, 1000); + assert_eq!(userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap(), gem_before + bonuses); + let items = &response["updated_value_list"]["item_list"]; + let unique: std::collections::HashSet<_> = items.members().map(|i| i["master_item_id"].as_i64()).collect(); + assert_eq!(items.len(), unique.len()); + let saved = userdata::get_acc(&key); + let replay = receive(request(), Session { key: key.clone(), body }).await.0.unwrap(); + assert!(replay["reward_list"].is_empty()); + assert_eq!(userdata::get_acc(&key), saved); + let second = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002, 1605002]} }).await.0.unwrap(); + assert_eq!(second["mission_list"].len(), 1); + assert_eq!(second["mission_list"][0]["master_mission_id"], 1653002); + assert_eq!(second["mission_list"][0]["progress"], 2); + } +} + const VARIABLE_MISSIONS: [[i64; 2]; 5] = [[1153001, 1153019], [1105001, 1105017], [1101001, 1101030], [1121001, 1121019], [1112001, 1112033]]; async fn mission(Login(key): Login) -> impl Responder { @@ -25,9 +69,9 @@ async fn mission(Login(key): Login) -> impl Responder { } let mut missions = userdata::get_acc_missions(&key); - if items::refresh_dailies(&mut missions, global::timestamp()) { - userdata::save_acc_missions(&key, missions.clone()); - } + items::refresh_dailies(&mut missions, global::timestamp()); + super::beginner_mission::refresh_account(&user, &mut missions); + userdata::save_acc_missions(&key, missions.clone()); Api(Some(object!{ "mission_list": missions @@ -39,6 +83,10 @@ async fn clear(Session { key, body }: Session) -> impl Responder { let mut cleared = array![]; for id in body["master_mission_ids"].members() { + if super::beginner_mission::contains(id.as_i64().unwrap_or(0)) { + let condition = databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64(); + if !matches!(condition, Some(26 | 65 | 66)) { continue; } + } let mission = items::get_mission_status(id.as_i64().unwrap(), &missions); if mission.is_empty() || mission["status"].as_i32().unwrap_or(0) >= 2 { continue; @@ -54,7 +102,11 @@ async fn clear(Session { key, body }: Session) -> impl Responder { })) } -async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Responder { +static CLAIM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +async fn receive(req: HttpRequest, Session { key, body }: Session) -> Api { + // Keep the read/grant/save sequence exclusive across HTTP workers. + let _claim = crate::lock_onto_mutex!(CLAIM_LOCK); let uid = global::get_uid(req.headers()); let now = global::timestamp(); @@ -66,9 +118,20 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo let mut touched_gem = false; let mut touched_coin = false; let mut touched_items = array![]; + let before = missions.clone(); + let mut requested: Vec<_> = body["master_mission_ids"].members().filter_map(|id| id.as_i64()).collect(); + let mut seen = std::collections::HashSet::new(); + requested.retain(|id| seen.insert(*id)); + // Process beginner cells in sheet order so a single claim can unlock the next sheet. + requested.sort_by_key(|id| super::beginner_mission::level(*id)); - for mission in body["master_mission_ids"].members() { - let mid = mission.as_i64().unwrap(); + for mid in requested { + if items::get_mission_status(mid, &missions)["status"] != 2 { + continue; + } + if super::beginner_mission::contains(mid) && !super::beginner_mission::claimable(mid, &missions) { + continue; + } let mission_info = databases::MISSION_LIST[mid.to_string()].clone(); for master in databases::MISSION_REWARDS[mission_info["masterMissionRewardId"].to_string()].members() { let reward_type = master["type"].as_i64().unwrap(); @@ -135,13 +198,28 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo user_id: uid, master_mission_id: mid, status: 3, - progress: if expire != 0 { m["progress"].clone() } else { JsonValue::Null }, + progress: if expire != 0 || super::beginner_mission::is_counter(mid) { m["progress"].clone() } else { JsonValue::Null }, expire_date: if expire != 0 { global::format_datetime(expire).into() } else { JsonValue::Null }, received_date: global::format_datetime(now) }).unwrap(); } } + for master in super::beginner_mission::new_rewards(&before, &missions) { + let reward_type = master["type"].as_i64().unwrap(); + rewards.push(object! { + give_type: master["giveType"].clone(), type: master["type"].clone(), + value: master["value"].clone(), level: master["level"].clone(), amount: master["amount"].clone() + }).unwrap(); + items::give_gift(&object! { + reward_type: reward_type, value: master["value"].clone(), amount: master["amount"].clone() + }, &mut user, &mut missions, &mut array![], &mut chats); + match reward_type { + 1 => touched_gem = true, + 4 => touched_coin = true, + _ => { touched_items.push(master["value"].clone()).unwrap(); } + } + } userdata::save_acc(&key, user.clone()); userdata::save_acc_chats(&key, chats); userdata::save_acc_missions(&key, missions); @@ -152,7 +230,9 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo } if !touched_items.is_empty() { let mut item_list = array![]; + let mut seen = std::collections::HashSet::new(); for id in touched_items.members() { + if !seen.insert(id.as_i64()) { continue; } for item in user["item_list"].members() { if item["master_item_id"] == *id { item_list.push(item.clone()).unwrap(); @@ -176,6 +256,9 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo updated_value_list["point_list"] = point_list; } + let mut sorted: Vec<_> = mission_list.members().cloned().collect(); + sorted.sort_by_key(|m| m["master_mission_id"].as_i64()); + let mission_list: JsonValue = sorted.into(); Api(Some(object!{ "reward_list": rewards, "gift_list": array![], diff --git a/src/router/multi_live.rs b/src/router/multi_live.rs index d8ca875..4ca8aa8 100644 --- a/src/router/multi_live.rs +++ b/src/router/multi_live.rs @@ -404,6 +404,7 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder // live_end scales exp / gold / bond / mission rewards off. let mut end_body = body.clone(); end_body["use_lp"] = lp_used.into(); + end_body["live_boost"] = live_boost.into(); if end_body["deck_slot"].is_null() { if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) { end_body["deck_slot"] = slot.into(); diff --git a/src/router/user.rs b/src/router/user.rs index ae5fcf7..46bd181 100644 --- a/src/router/user.rs +++ b/src/router/user.rs @@ -48,14 +48,17 @@ async fn deck(Session { key, body }: Session) -> impl Responder { } } userdata::save_acc(&key, user.clone()); - + let mut missions = userdata::get_acc_missions(&key); + let cleared = super::beginner_mission::refresh(&user, &mut missions); + userdata::save_acc_missions(&key, missions); + Api(Some(object!{ "deck": { "slot": body["slot"].clone(), "leader_role": 0, "main_card_ids": body["main_card_ids"].clone() }, - "clear_mission_ids": [] + "clear_mission_ids": cleared })) } @@ -212,6 +215,11 @@ async fn register_password(Session { key, body }: Session) -> impl Responder { let user = userdata::get_acc(&key); userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &body["pass"].to_string()); + if !body["pass"].as_str().unwrap_or("").is_empty() { + let mut missions = userdata::get_acc_missions(&key); + super::beginner_mission::advance(25, None, 1, &mut missions); + userdata::save_acc_missions(&key, missions); + } Api(Some(array![])) } diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index e219bb1..c273334 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -357,7 +357,9 @@ pub fn get_acc_home(auth_key: &str) -> JsonValue { user } pub fn get_acc_missions(auth_key: &str) -> JsonValue { - get_data(auth_key, "missions") + let mut missions = get_data(auth_key, "missions"); + super::beginner_mission::ensure(&mut missions); + missions } pub fn get_acc_loginbonus(auth_key: &str) -> JsonValue { get_data(auth_key, "loginbonus")