Redo story stuff

This commit is contained in:
Ethan O'Brien
2026-09-25 12:06:46 -05:00
parent 7ef418fc51
commit 29bf71eb9b
8 changed files with 368 additions and 30 deletions
+68
View File
@@ -60,6 +60,7 @@ pub fn add_chat_from_chapter_id(chapter_id: i64, chats: &mut JsonValue) -> bool
}
async fn home(Login(key): Login) -> impl Responder {
refresh_birthday(&key, &userdata::get_acc(&key));
let chats = userdata::get_acc_chats(&key);
let mut rooms = array![];
@@ -75,6 +76,54 @@ async fn home(Login(key): Login) -> impl Responder {
}))
}
pub fn refresh_birthday(key: &str, user: &JsonValue) {
if !super::user::valid_birthday(user["user"]["birthday"].as_str().unwrap_or("")) { return; }
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
if !is_birthday(user, now) { return; }
let mut chats = userdata::get_acc_chats(key);
let mut missions = userdata::get_acc_missions(key);
if grant_birthday_chats(user, &mut chats, &mut missions, now) {
userdata::save_acc_chats(key, chats);
userdata::save_acc_missions(key, missions);
}
}
fn is_birthday(user: &JsonValue, now: u64) -> bool {
let date = global::format_datetime(now);
let birthday = user["user"]["birthday"].as_str().unwrap_or("");
birthday == format!("{}{}", &date[5..7], &date[8..10])
}
fn grant_birthday_chats(user: &JsonValue, chats: &mut JsonValue, missions: &mut JsonValue, now: u64) -> bool {
if !is_birthday(user, now) { return false; }
let mut changed = false;
for (_, master) in databases::MISSION_LIST.entries() {
if master["conditionType"] != 75 { continue; }
let character = &master["conditionValues"][0];
let target = master["conditionNumber"].as_i64().unwrap();
if !user["character_list"].members().any(|c|
c["master_character_id"] == *character && c["exp"].as_i64().unwrap_or(0) >= target) { continue; }
let id = master["id"].as_i64().unwrap();
if items::get_mission_status(id, missions)["status"] == 3 { continue; }
for reward in databases::MISSION_REWARDS[master["masterMissionRewardId"].to_string()].members().filter(|r| r["type"] == 16) {
for (_, by_room) in databases::CHATS.entries() {
for (_, room) in by_room.entries().filter(|(_, r)| r["id"] == reward["value"]) {
add_chat(room["masterChatId"].as_i64().unwrap(), room["roomId"].as_i64().unwrap(), chats);
}
}
}
if !missions.members().any(|m| m["master_mission_id"] == id) {
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
}
for mission in missions.members_mut().filter(|m| m["master_mission_id"] == id) {
mission["status"] = 3.into();
mission["progress"] = target.into();
}
changed = true;
}
changed
}
async fn start() -> impl Responder {
Api(Some(object!{"select_talk_id_list":[],"get_item_list":[],"is_read":0}))
}
@@ -102,6 +151,25 @@ async fn end(Session { key, body }: Session) -> impl Responder {
mod tests {
use super::*;
#[test]
fn birthday_chats_require_the_date_and_bond_and_are_not_duplicated() {
let _test = crate::runtime::lock_test_data_path();
let user = object! { user: {birthday: "0531"}, character_list: [
{master_character_id: 1001, exp: 50}, {master_character_id: 1002, exp: 49}
]};
let mut chats = array![];
let mut missions = array![];
let day = global::parse_datetime("2024/05/31 12:00:00").unwrap();
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day - 86400));
assert!(grant_birthday_chats(&user, &mut chats, &mut missions, day));
assert_eq!(chats.len(), 1);
assert_eq!(chats[0]["chapter_id"], 100100301);
assert_eq!(missions[0]["status"], 3);
assert_eq!(missions[0]["progress"], 50);
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day));
assert_eq!(chats.len(), 1);
}
// Verbatim from an official /api/chat/home capture (JP log, the 65-occurrence
// baseline seen on accounts that had earned no extra stamps yet). Both the JP and EN
// chat_stamp tables reproduce it exactly from _initialStamp, which is what lets
+1
View File
@@ -198,6 +198,7 @@ fn init_star_event(event: &mut JsonValue) {
async fn event(Session { key, body }: Session) -> impl Responder {
let master_event_id = body["master_event_id"].as_u32().unwrap();
super::story::visit_event(&key, master_event_id);
let mut event = get_event_data(&key, master_event_id);
let is_star_event = STAR_EVENT_IDS.contains(&master_event_id);
+3 -1
View File
@@ -88,12 +88,14 @@ lazy_static! {
}
async fn home(Login(key): Login) -> impl Responder {
let account = userdata::get_acc(&key);
super::chat::refresh_birthday(&key, &account);
let mut user = userdata::get_acc_home(&key);
check_gifts(&mut user);
let mut user_missions = userdata::get_acc_missions(&key);
let mut clear = super::beginner_mission::refresh_account(&userdata::get_acc(&key), &mut user_missions);
let mut clear = super::beginner_mission::refresh_account(&account, &mut user_missions);
for id in items::completed_daily_mission(1253003, &mut user_missions).members() {
clear.push(id.clone()).unwrap();
}
+1
View File
@@ -834,6 +834,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(); }
super::story::refresh(&user, &mut user_missions);
userdata::save_acc(&key, user.clone());
userdata::save_acc_home(&key, user2.clone());
+1
View File
@@ -114,6 +114,7 @@ async fn mission(Login(key): Login) -> impl Responder {
let mut missions = userdata::get_acc_missions(&key);
items::refresh_dailies(&mut missions, global::timestamp());
super::story::refresh(&user, &mut missions);
super::beginner_mission::refresh_account(&user, &mut missions);
userdata::save_acc_missions(&key, missions.clone());
+249 -28
View File
@@ -1,41 +1,262 @@
use jzon::{object};
use actix_web::{web, Responder};
use crate::router::{userdata, databases, Session, Api};
use jzon::{array, object, JsonValue};
use actix_web::{web, HttpRequest};
use crate::router::{chat, global, items, userdata, databases, Session, Api};
use databases::csv::{table, Region};
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.route("/story/read", web::post().to(read));
}
async fn read(Session { key, body }: Session) -> impl Responder {
let mut user = userdata::get_acc(&key);
let part = body["master_story_part_id"].as_i64().unwrap();
let master_id = databases::STORY[part.to_string()]["masterStoryId"].as_i64().unwrap();
static READ_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let index = user["story_list"].members().position(|r| r["master_story_id"] == master_id);
fn has_read(user: &JsonValue, story: i64, part: i64) -> bool {
user["story_list"].members().any(|s| s["master_story_id"] == story
&& s["master_story_part_ids"].contains(part))
}
if index.is_none() {
user["story_list"].push(object!{
master_story_id: master_id,
master_story_part_ids: []
}).unwrap();
}
for story in user["story_list"].members_mut() {
if story["master_story_id"] == master_id && !story["master_story_part_ids"].contains(part) {
story["master_story_part_ids"].push(part).unwrap();
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) {
let mut counts = std::collections::HashMap::<i64, i64>::new();
for live in user["live_list"].members() {
let song = &databases::MUSIC[live["master_live_id"].to_string()];
if !song.is_null() {
*counts.entry(song["bandCategory"].as_i64().unwrap_or(0)).or_default()
+= live["clear_count"].as_i64().unwrap_or(0);
}
}
for (_, master) in databases::MISSION_LIST.entries() {
let condition = master["conditionType"].as_i64().unwrap_or(0);
if !matches!(condition, 73 | 78) { continue; }
let id = master["id"].as_i64().unwrap();
let value = master["conditionValues"][0].as_i64().unwrap_or(0);
let count = if condition == 73 { counts.get(&value).copied().unwrap_or(0) }
else { super::event::get_points(value as u32, user) };
if condition == 78 && count == 0 { continue; }
set_progress(id, count, missions);
}
}
userdata::save_acc(&key, user.clone());
fn set_progress(id: i64, count: i64, missions: &mut JsonValue) {
if !missions.members().any(|m| m["master_mission_id"] == id) {
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
}
let master = &databases::MISSION_LIST[id.to_string()];
let target = master["conditionNumber"].as_i64().unwrap();
let mission = missions.members_mut().find(|m| m["master_mission_id"] == id).unwrap();
if mission["status"].as_i64().unwrap_or(1) >= 2 { return; }
let progress = count.max(mission["progress"].as_i64().unwrap_or(0)).min(target);
mission["progress"] = progress.into();
mission["status"] = if progress >= target { 2 } else { 1 }.into();
}
pub fn visit_event(key: &str, event: u32) {
let user = userdata::get_acc(key);
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
if !super::event::is_in_session(event, now) { return; }
let mut missions = userdata::get_acc_missions(key);
for (_, master) in databases::MISSION_LIST.entries() {
if master["conditionType"] == 79 && master["conditionValues"][0] == event {
set_progress(master["id"].as_i64().unwrap(), 1, &mut missions);
}
}
refresh(&user, &mut missions);
userdata::save_acc_missions(key, missions);
}
Api(Some(object!{
"gift_list":[],
"updated_value_list":{
"story_list": user["story_list"].clone()
},
"reward_list":[],
"clear_mission_ids":[]
}))
async fn read(req: HttpRequest, Session { key, body }: Session) -> Api {
let _read = crate::lock_onto_mutex!(READ_LOCK);
let Some(part_id) = body["master_story_part_id"].as_i64() else { return Api(None); };
let part = &databases::STORY[part_id.to_string()];
let Some(story_id) = part["masterStoryId"].as_i64() else { return Api(None); };
let mut user = userdata::get_acc(&key);
let empty = || object! {gift_list: [], updated_value_list: [], reward_list: [], clear_mission_ids: []};
if has_read(&user, story_id, part_id) { return Api(Some(empty())); }
if let Some(previous) = databases::STORY.entries().map(|(_, p)| p)
.filter(|p| p["masterStoryId"] == story_id && p["number"].as_i64() < part["number"].as_i64())
.max_by_key(|p| p["number"].as_i64()) {
if !has_read(&user, story_id, previous["id"].as_i64().unwrap()) { return Api(None); }
}
let mut missions = userdata::get_acc_missions(&key);
refresh(&user, &mut missions);
let releases = table(Region::Jp, "story_release");
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
let stories = table(Region::Jp, "story");
let Some(story_master) = stories.members().find(|s| s["id"] == story_id) else { return Api(None); };
let mut fully_open = false;
if let Some(release) = releases.members().find(|r| r["masterStoryPartId"] == part_id) {
fully_open = global::parse_datetime(release["openedAtAfterEvent"].as_str().unwrap_or(""))
.is_some_and(|at| now >= at);
if story_master["type"] == 3 {
let event_stories = table(Region::Jp, "event_story");
let ended = event_stories.members().find(|e| e["masterStoryId"] == story_id)
.and_then(|e| {
let event = &databases::EVENTS[e["masterEventId"].to_string()];
global::parse_datetime(databases::RELEASE_LABEL[event["masterReleaseLabelId"].to_string()]["closedAt"].as_str().unwrap_or(""))
}).is_some_and(|end| now > end);
fully_open &= ended;
}
let mission = release["masterMissionId"].as_i64().unwrap_or(0);
if !fully_open {
let rank = items::get_user_rank_data(user["user"]["exp"].as_i64().unwrap_or(0))["rank"].as_i64().unwrap_or(0);
if rank < release["userRank"].as_i64().unwrap_or(0) { return Api(None); }
let live = release["masterLiveId"].as_i64().unwrap_or(0);
let score = release["liveScore"].as_i64().unwrap_or(0);
if live != 0 && score != 0 && !user["live_list"].members().any(|l|
l["master_live_id"] == live && l["clear_count"].as_i64().unwrap_or(0) > 0
&& l["high_score"].as_i64().unwrap_or(0) >= score) { return Api(None); }
}
if !fully_open && mission != 0 && !missions.members().any(|m|
m["master_mission_id"] == mission && m["status"].as_i64().unwrap_or(0) >= 2) {
return Api(None);
}
}
if !fully_open && (!super::event::release_label_is_open(part["masterReleaseLabelId"].as_i64().unwrap_or(0), now)
|| !super::event::release_label_is_open(story_master["masterReleaseLabelId"].as_i64().unwrap_or(0), now)) {
return Api(None);
}
let mut home = userdata::get_acc_home(&key);
let mut chats = userdata::get_acc_chats(&key);
let mut response = empty();
let mut rooms = array![];
let mut chapters = array![];
let region = if items::get_region(req.headers()) { Region::Jp } else { Region::En };
let mut rewards: Vec<_> = table(region, "story_reward").members()
.filter(|r| r["id"] == part["masterStoryRewardId"]).cloned().collect();
// Official replies group room rewards before chapter rewards.
rewards.sort_by_key(|r| (r["type"].as_i64(), r["number"].as_i64()));
for reward in rewards {
let value = reward["value"].as_i64().unwrap();
match reward["type"].as_i64().unwrap() {
1 => {
if home["home"]["gift_list"].len() >= items::GIFT_LIMIT { return Api(None); }
let mut gift = reward.clone();
let next_id = loop {
let id = rand::random::<u64>() & ((1u64 << 53) - 1);
if id != 0 && !home["home"]["gift_list"].members().any(|g| g["id"] == id) { break id; }
};
gift["id"] = next_id.into();
let reason = if region == Region::Jp { "スクールアイドルの日常の報酬です。" } else { "Daily Life of School idols reward." };
let gift = items::gift_item(&gift, reason, &mut home);
response["gift_list"].push(gift).unwrap();
}
16 => {} // Chapter rewards below materialize room ownership.
17 => {
if !chat::add_chat_from_chapter_id(value, &mut chats) { continue; }
chapters.push(value).unwrap();
let chapter = &databases::CHAPTERS_MASTER[value.to_string()];
let room = databases::CHATS[chapter["masterChatId"].to_string()][chapter["roomId"].to_string()]["id"].clone();
rooms.push(room).unwrap();
}
_ => return Api(None),
}
response["reward_list"].push(object! {
type: reward["type"].clone(), value: value,
level: reward["level"].clone(), amount: reward["amount"].clone()
}).unwrap();
}
if !chapters.is_empty() {
response["updated_value_list"] = object! {master_chat_room_ids: rooms, master_chat_chapter_ids: chapters};
}
if !user["story_list"].members().any(|s| s["master_story_id"] == story_id) {
user["story_list"].push(object! {master_story_id: story_id, master_story_part_ids: []}).unwrap();
}
for story in user["story_list"].members_mut().filter(|s| s["master_story_id"] == story_id) {
story["master_story_part_ids"].push(part_id).unwrap();
}
userdata::save_acc_home(&key, home);
userdata::save_acc_chats(&key, chats);
userdata::save_acc_missions(&key, missions);
userdata::save_acc(&key, user);
Api(Some(response))
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn event_entry_and_point_thresholds_unlock_story_in_its_original_period() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Event story test").unwrap();
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2023/04/22 12:00:00").unwrap(), server_time_set: global::timestamp()
});
let request = || actix_web::test::TestRequest::default().to_http_request();
let part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
assert!(read(request(), part(40001001)).await.0.is_none());
visit_event(&key, 101);
assert!(read(request(), part(40001001)).await.0.is_some());
let mut user = userdata::get_acc(&key);
super::super::event::give_event_points(101, 4999, &mut user);
userdata::save_acc(&key, user);
assert!(read(request(), part(40001002)).await.0.is_none());
let mut user = userdata::get_acc(&key);
super::super::event::give_event_points(101, 1, &mut user);
userdata::save_acc(&key, user);
assert!(read(request(), part(40001002)).await.0.is_some());
assert!(read(request(), part(40001003)).await.0.is_none());
}
#[actix_web::test]
async fn story_unlocks_before_and_after_full_release() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Story unlock test").unwrap();
let request = || actix_web::test::TestRequest::default().to_http_request();
let read_part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2023/12/01 12:00:00").unwrap(), server_time_set: global::timestamp()
});
assert!(read(request(), read_part(210001001)).await.0.is_none());
let mut user = userdata::get_acc(&key);
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
userdata::save_acc(&key, user);
assert!(read(request(), read_part(210001001)).await.0.is_some());
assert!(read(request(), read_part(210001002)).await.0.is_none());
let mut user = userdata::get_acc(&key);
user["live_list"][0]["clear_count"] = 10.into();
userdata::save_acc(&key, user);
assert!(read(request(), read_part(210001002)).await.0.is_some());
// Event parts remain gated before full release, including previous-part order.
assert!(read(request(), read_part(40001001)).await.0.is_none());
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2024/03/01 12:00:00").unwrap(), server_time_set: global::timestamp()
});
assert!(read(request(), read_part(40001002)).await.0.is_none());
assert!(read(request(), read_part(40001001)).await.0.is_some());
assert!(read(request(), read_part(40001002)).await.0.is_some());
}
#[actix_web::test]
async fn story_rewards_persist_and_replays_do_not_pay_again() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Story test").unwrap();
userdata::save_acc_chats(&key, array![]);
let request = || actix_web::test::TestRequest::default().to_http_request();
let reply = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
// jp/logs.txt:1871-1902, the first Aqours introduction read.
assert_eq!(reply["updated_value_list"], object! {
master_chat_room_ids: [2001001, 2101001], master_chat_chapter_ids: [200100101, 210100101]
});
assert_eq!(reply["reward_list"].len(), 4);
assert_eq!(reply["reward_list"][0]["type"], 16);
assert_eq!(userdata::get_acc_chats(&key).len(), 2);
assert!(has_read(&userdata::get_acc(&key), 129999, 20000001));
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
assert!(repeat["reward_list"].is_empty());
// jp/logs.txt:18507-18538: daily-life gems go to gifts, not straight to the wallet.
let mut user = userdata::get_acc(&key);
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
userdata::save_acc(&key, user);
let gems = userdata::get_acc(&key)["gem"].clone();
let before = userdata::get_acc_home(&key)["home"]["gift_list"].len();
let first = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
assert_eq!(first["gift_list"].len(), 1);
assert_eq!(first["gift_list"][0]["amount"], 100);
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
assert_eq!(userdata::get_acc(&key)["gem"], gems);
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
assert!(repeat["gift_list"].is_empty());
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
assert!(read(request(), Session {key, body: object! {master_story_part_id: -1}}).await.0.is_none());
}
}
+39 -1
View File
@@ -173,7 +173,11 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
user["user"]["master_title_ids"][0] = body["master_title_ids"][0].clone();
}
if !body["birthday"].is_null() {
user["user"]["birthday"][0] = body["birthday"].clone();
let Some(birthday) = body["birthday"].as_str() else { return Api(None); };
if !valid_birthday(birthday) { return Api(None); }
let previous = user["user"]["birthday"].as_str().unwrap_or("");
if valid_birthday(previous) && previous != birthday { return Api(None); }
user["user"]["birthday"] = birthday.into();
}
userdata::save_acc(&key, user.clone());
@@ -184,6 +188,40 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
}))
}
pub(super) fn valid_birthday(value: &str) -> bool {
if value.len() != 4 || !value.bytes().all(|b| b.is_ascii_digit()) { return false; }
let month: usize = value[..2].parse().unwrap();
let day: u8 = value[2..].parse().unwrap();
let days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
(1..=12).contains(&month) && day > 0 && day <= days[month - 1]
}
#[cfg(test)]
mod birthday_tests {
use super::*;
#[actix_web::test]
async fn birthday_update_round_trips_as_mmdd() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Birthday test").unwrap();
// jp/logs.txt:157322-157353 stores this as a string, not an array.
user_post(Session {key: key.clone(), body: object! {birthday: "0331"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
user_post(Session {key: key.clone(), body: object! {birthday: "0230"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
user_post(Session {key: key.clone(), body: object! {birthday: "0401"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
let mut legacy = userdata::get_acc(&key);
legacy["user"]["birthday"] = array!["0331"];
userdata::save_acc(&key, legacy);
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
assert!(valid_birthday("0229"));
for invalid in ["", "0001", "1301", "0100", "0431", "abcd", "2024"] {
assert!(!valid_birthday(invalid));
}
}
}
pub async fn announcement(Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc_home(&key);
+6
View File
@@ -337,6 +337,12 @@ pub fn get_acc(auth_key: &str) -> JsonValue {
let mut user = get_data(auth_key, "userdata");
cleanup_account(&mut user);
let mut changed = remove_deleted_custom_songs(&mut user);
// Repair the old /user writer's ["MMDD"] shape without losing the chosen birthday.
if user["user"]["birthday"].is_array() {
let birthday = user["user"]["birthday"][0].as_str().unwrap_or("").to_owned();
user["user"]["birthday"] = if super::user::valid_birthday(&birthday) { birthday } else { String::new() }.into();
changed = true;
}
if remove_deleted_custom_cards(&mut user) {
changed = true;
}