mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-12 01:24:27 +08:00
Compare commits
6
Commits
8270662666
...
33eb7a4002
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33eb7a4002 | ||
|
|
29bf71eb9b | ||
|
|
7ef418fc51 | ||
|
|
e387f1ff0e | ||
|
|
125061863a | ||
|
|
dfc29c70e4 |
@@ -1,7 +1,7 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
println!("cargo:rerun-if-changed=src");
|
println!("cargo:rerun-if-changed=src");
|
||||||
println!("cargo:rerun-if-changed=webui/index.html");
|
println!("cargo:rerun-if-changed=webui");
|
||||||
|
|
||||||
let target = std::env::var("TARGET").unwrap();
|
let target = std::env::var("TARGET").unwrap();
|
||||||
if target == "aarch64-linux-android" {
|
if target == "aarch64-linux-android" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod gree;
|
pub mod gree;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
pub mod custom_card;
|
pub mod custom_card;
|
||||||
|
pub mod custom_group;
|
||||||
pub mod custom_3dmv;
|
pub mod custom_3dmv;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod announcements;
|
pub mod announcements;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use lazy_static::lazy_static;
|
||||||
|
use rusqlite::params;
|
||||||
|
use jzon::{array, object, JsonValue};
|
||||||
|
use crate::sql::SQLite;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref DATABASE: SQLite = SQLite::new("custom_groups.db", setup_tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const FIRST_ID: i64 = 10_000;
|
||||||
|
|
||||||
|
fn setup_tables(conn: &rusqlite::Connection) {
|
||||||
|
conn.execute_batch("CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS custom_groups_unique_name ON groups(name COLLATE NOCASE);
|
||||||
|
CREATE TABLE IF NOT EXISTS logos (group_id INTEGER PRIMARY KEY, md5 TEXT NOT NULL, size INTEGER NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS sequence (id INTEGER PRIMARY KEY CHECK (id=1), last_id INTEGER NOT NULL);").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> JsonValue {
|
||||||
|
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||||
|
let Ok(mut stmt) = conn.prepare("SELECT id, name, name_en, COALESCE(md5, ''), COALESCE(size, 0) FROM groups LEFT JOIN logos ON group_id=id ORDER BY id") else { return array![]; };
|
||||||
|
let Ok(rows) = stmt.query_map(params![], |row| Ok(object! {
|
||||||
|
"id": row.get::<_, i64>(0)?,
|
||||||
|
"name": row.get::<_, String>(1)?,
|
||||||
|
"name_en": row.get::<_, String>(2)?,
|
||||||
|
"logo_md5": row.get::<_, String>(3)?,
|
||||||
|
"logo_size": row.get::<_, i64>(4)?
|
||||||
|
})) else { return array![]; };
|
||||||
|
let mut result = array![];
|
||||||
|
for row in rows.flatten() { result.push(row).unwrap(); }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_logo(id: i64, md5: &str, size: i64) -> Result<(), rusqlite::Error> {
|
||||||
|
DATABASE.lock_and_transact(|conn| {
|
||||||
|
conn.execute("INSERT INTO logos (group_id, md5, size) VALUES (?1, ?2, ?3) ON CONFLICT(group_id) DO UPDATE SET md5=excluded.md5, size=excluded.size", params!(id, md5, size))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_logo(md5: &str) -> bool {
|
||||||
|
DATABASE.lock_and_select("SELECT group_id FROM logos WHERE md5=?1", params!(md5)).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exists(id: i64) -> bool {
|
||||||
|
if id < FIRST_ID { return false; }
|
||||||
|
DATABASE.lock_and_select("SELECT id FROM groups WHERE id=?1", params!(id)).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(name: &str, name_en: &str) -> Result<i64, rusqlite::Error> {
|
||||||
|
DATABASE.lock_and_transact(|conn| {
|
||||||
|
let last: i64 = conn.query_row("SELECT last_id FROM sequence WHERE id=1", [], |row| row.get(0)).unwrap_or(FIRST_ID - 1);
|
||||||
|
let id = last + 1;
|
||||||
|
conn.execute("INSERT INTO groups (id, name, name_en) VALUES (?1, ?2, ?3)", params!(id, name, name_en))?;
|
||||||
|
conn.execute("INSERT INTO sequence (id, last_id) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET last_id=?1", params!(id))?;
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adds_logo_storage_to_existing_group_database() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch("CREATE TABLE groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL);
|
||||||
|
INSERT INTO groups VALUES (10000, 'Team A', 'Team A');").unwrap();
|
||||||
|
setup_tables(&conn);
|
||||||
|
setup_tables(&conn);
|
||||||
|
assert_eq!(conn.query_row("SELECT name FROM groups WHERE id=10000", [], |row| row.get::<_, String>(0)).unwrap(), "Team A");
|
||||||
|
conn.execute("INSERT INTO logos VALUES (10000, 'test-hash', 42)", []).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ pub const CARD: &str = "card";
|
|||||||
pub const CARD_UPLOAD: &str = "card.upload";
|
pub const CARD_UPLOAD: &str = "card.upload";
|
||||||
pub const CARD_PUBLISH: &str = "card.publish";
|
pub const CARD_PUBLISH: &str = "card.publish";
|
||||||
pub const CARD_EDIT: &str = "card.edit";
|
pub const CARD_EDIT: &str = "card.edit";
|
||||||
|
pub const GROUP_MANAGE: &str = "group.manage";
|
||||||
|
|
||||||
pub const PERMISSION: &str = "permission";
|
pub const PERMISSION: &str = "permission";
|
||||||
pub const PERMISSION_GRANT: &str = "permission.grant";
|
pub const PERMISSION_GRANT: &str = "permission.grant";
|
||||||
@@ -31,6 +32,7 @@ pub const MV_EDIT: &str = "3dmv.edit";
|
|||||||
pub const SCOPES: &[&str] = &[
|
pub const SCOPES: &[&str] = &[
|
||||||
ALL,
|
ALL,
|
||||||
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
|
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
|
||||||
|
GROUP_MANAGE,
|
||||||
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
|
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
|
||||||
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
|
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
|
||||||
MV, MV_EDIT
|
MV, MV_EDIT
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod user;
|
|||||||
pub mod purchase;
|
pub mod purchase;
|
||||||
pub mod tutorial;
|
pub mod tutorial;
|
||||||
pub mod mission;
|
pub mod mission;
|
||||||
|
pub mod beginner_mission;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod lottery;
|
pub mod lottery;
|
||||||
pub mod friend;
|
pub mod friend;
|
||||||
@@ -24,6 +25,7 @@ pub mod card;
|
|||||||
pub mod shop;
|
pub mod shop;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
pub mod custom_card;
|
pub mod custom_card;
|
||||||
|
pub mod custom_group;
|
||||||
pub mod custom_3dmv;
|
pub mod custom_3dmv;
|
||||||
pub mod rich_text;
|
pub mod rich_text;
|
||||||
pub mod webui;
|
pub mod webui;
|
||||||
@@ -301,6 +303,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
);
|
);
|
||||||
cfg.configure(custom_song::web_routes);
|
cfg.configure(custom_song::web_routes);
|
||||||
cfg.configure(custom_card::web_routes);
|
cfg.configure(custom_card::web_routes);
|
||||||
|
cfg.configure(custom_group::web_routes);
|
||||||
cfg.configure(custom_3dmv::web_routes);
|
cfg.configure(custom_3dmv::web_routes);
|
||||||
cfg.configure(web::routes);
|
cfg.configure(web::routes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<i64>, 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<Item = &'static JsonValue> {
|
||||||
|
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<i64> {
|
||||||
|
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<JsonValue> {
|
||||||
|
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::<i64>(), 1000);
|
||||||
|
assert!(claimable(1605002, &after));
|
||||||
|
assert!(!home_status(&after).1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -134,6 +134,11 @@ async fn reinforce(Session { key, body }: Session) -> impl Responder {
|
|||||||
let mut clear_mission_ids = array![];
|
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 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());
|
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 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 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());
|
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 mut clear_mission_ids = array![];
|
||||||
|
|
||||||
let card = do_reinforce(&mut user, &body, "", 0, true, &mut missions, &mut chats, &mut clear_mission_ids);
|
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(&key, user.clone());
|
||||||
userdata::save_acc_chats(&key, chats);
|
userdata::save_acc_chats(&key, chats);
|
||||||
|
|||||||
@@ -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 {
|
async fn home(Login(key): Login) -> impl Responder {
|
||||||
|
refresh_birthday(&key, &userdata::get_acc(&key));
|
||||||
let chats = userdata::get_acc_chats(&key);
|
let chats = userdata::get_acc_chats(&key);
|
||||||
|
|
||||||
let mut rooms = array![];
|
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 {
|
async fn start() -> impl Responder {
|
||||||
Api(Some(object!{"select_talk_id_list":[],"get_item_list":[],"is_read":0}))
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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
|
// 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
|
// 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
|
// chat_stamp tables reproduce it exactly from _initialStamp, which is what lets
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
|
|||||||
use crate::router::databases::csv::{table, Region};
|
use crate::router::databases::csv::{table, Region};
|
||||||
use crate::router::custom_song::audio;
|
use crate::router::custom_song::audio;
|
||||||
use crate::database::custom_card as database;
|
use crate::database::custom_card as database;
|
||||||
|
use crate::database::custom_group;
|
||||||
use crate::database::permissions;
|
use crate::database::permissions;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
use crate::lock_onto_mutex;
|
use crate::lock_onto_mutex;
|
||||||
@@ -700,7 +701,7 @@ pub fn owned_runtime_ids(user: &JsonValue) -> Vec<i64> {
|
|||||||
// The catalog is filtered per requesting user: everyone gets the published
|
// The catalog is filtered per requesting user: everyone gets the published
|
||||||
// cards, the owner additionally gets their drafts, and a game account that
|
// cards, the owner additionally gets their drafts, and a game account that
|
||||||
// already owns a card keeps resolving it even if it was since unpublished
|
// already owns a card keeps resolving it even if it was since unpublished
|
||||||
async fn list(Login(key): Login) -> impl Responder {
|
async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||||
if disabled() {
|
if disabled() {
|
||||||
// As if the endpoint doesn't exist - the client treats this as feature-off
|
// As if the endpoint doesn't exist - the client treats this as feature-off
|
||||||
return Api(None);
|
return Api(None);
|
||||||
@@ -713,9 +714,17 @@ async fn list(Login(key): Login) -> impl Responder {
|
|||||||
nerf_clamp_card(card);
|
nerf_clamp_card(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let characters = database::get_characters_for_cards(uid, &cards);
|
let mut characters = database::get_characters_for_cards(uid, &cards);
|
||||||
|
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
|
||||||
|
for character in characters.members_mut() {
|
||||||
|
if character["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
|
||||||
|
character["master_group_id"] = CHARACTER_GROUP_ID.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"revision": database::get_revision(),
|
"revision": database::get_revision(),
|
||||||
|
"groups": custom_group::list(),
|
||||||
"characters": characters,
|
"characters": characters,
|
||||||
"cards": cards
|
"cards": cards
|
||||||
}))
|
}))
|
||||||
@@ -1352,6 +1361,13 @@ fn valid_color(color: &str) -> bool {
|
|||||||
// Every column the uploader never supplies is forced to the value all 172
|
// Every column the uploader never supplies is forced to the value all 172
|
||||||
// imported characters carry. Numbers, not enum names
|
// imported characters carry. Numbers, not enum names
|
||||||
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
||||||
|
let group_text = text_of(fields, "character_master_group_id", stored, "master_group_id");
|
||||||
|
let group_id = if group_text.is_empty() { CHARACTER_GROUP_ID } else {
|
||||||
|
group_text.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?
|
||||||
|
};
|
||||||
|
if group_id != CHARACTER_GROUP_ID && !custom_group::exists(group_id) {
|
||||||
|
return Err(String::from("Custom group does not exist"));
|
||||||
|
}
|
||||||
for (key, label) in [
|
for (key, label) in [
|
||||||
("character_name", "Character name"),
|
("character_name", "Character name"),
|
||||||
("character_name_en", "Character English name"),
|
("character_name_en", "Character English name"),
|
||||||
@@ -1398,7 +1414,7 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue
|
|||||||
"category": CHARACTER_CATEGORY_OTHER,
|
"category": CHARACTER_CATEGORY_OTHER,
|
||||||
"school_grade": CHARACTER_SCHOOL_GRADE,
|
"school_grade": CHARACTER_SCHOOL_GRADE,
|
||||||
"chara_category": CHARACTER_CHARA_CATEGORY,
|
"chara_category": CHARACTER_CHARA_CATEGORY,
|
||||||
"master_group_id": CHARACTER_GROUP_ID,
|
"master_group_id": group_id,
|
||||||
"sprite_name": "",
|
"sprite_name": "",
|
||||||
"display_order": master_character_id,
|
"display_order": master_character_id,
|
||||||
"height": text("height"),
|
"height": text("height"),
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
|
||||||
|
use jzon::{object, JsonValue};
|
||||||
|
use crate::database::{custom_group, permissions};
|
||||||
|
use crate::router::{userdata, webui, rich_text};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 6;
|
||||||
|
|
||||||
|
pub fn web_routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(web::scope("/custom_group")
|
||||||
|
.route("/list", web::get().to(list))
|
||||||
|
.route("/create", web::post().to(create))
|
||||||
|
.route("/{id}/logo", web::post().to(upload_logo))
|
||||||
|
.route("/data/{hash}/{file}", web::get().to(logo_data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_manage(req: &HttpRequest) -> bool {
|
||||||
|
webui::get_login_token(req)
|
||||||
|
.and_then(|token| userdata::webui_login_token(&token))
|
||||||
|
.and_then(|key| userdata::get_acc(&key)["user"]["id"].as_i64())
|
||||||
|
.is_some_and(|id| permissions::has(id, permissions::GROUP_MANAGE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_logo(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let mut reader = image::ImageReader::new(Cursor::new(bytes))
|
||||||
|
.with_guessed_format().map_err(|_| "Invalid image")?;
|
||||||
|
if !matches!(reader.format(), Some(image::ImageFormat::Png | image::ImageFormat::Jpeg)) {
|
||||||
|
return Err("Logo must be PNG or JPEG".into());
|
||||||
|
}
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(4096);
|
||||||
|
limits.max_image_height = Some(4096);
|
||||||
|
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||||
|
reader.limits(limits);
|
||||||
|
let image = reader.decode().map_err(|_| "Invalid or oversized image")?.thumbnail(512, 256);
|
||||||
|
let mut png = Cursor::new(Vec::new());
|
||||||
|
image.write_to(&mut png, image::ImageFormat::Png).map_err(|_| "Could not encode logo")?;
|
||||||
|
Ok(png.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_logo(req: HttpRequest, id: web::Path<i64>, mut payload: web::Payload) -> HttpResponse {
|
||||||
|
if !can_manage(&req) { return HttpResponse::Forbidden().finish(); }
|
||||||
|
if !custom_group::exists(*id) { return HttpResponse::NotFound().finish(); }
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = payload.next().await {
|
||||||
|
let Ok(chunk) = chunk else { return HttpResponse::BadRequest().finish(); };
|
||||||
|
if bytes.len() + chunk.len() > 4 * 1024 * 1024 { return HttpResponse::PayloadTooLarge().finish(); }
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
let png = match web::block(move || encode_logo(&bytes)).await {
|
||||||
|
Ok(Ok(png)) => png,
|
||||||
|
Ok(Err(message)) => return reply(object! { "result": "ERR", "message": message }),
|
||||||
|
Err(_) => return HttpResponse::InternalServerError().finish(),
|
||||||
|
};
|
||||||
|
let hash = format!("{:x}", md5::compute(&png));
|
||||||
|
let dir = crate::runtime::get_data_path("custom_groups");
|
||||||
|
if store_logo(&dir, &hash, &png).is_err()
|
||||||
|
|| custom_group::set_logo(*id, &hash, png.len() as i64).is_err() {
|
||||||
|
return HttpResponse::InternalServerError().finish();
|
||||||
|
}
|
||||||
|
crate::database::custom_song::bump_revision();
|
||||||
|
crate::database::custom_card::bump_revision();
|
||||||
|
reply(object! { "result": "OK", "logo_md5": hash, "logo_size": png.len() })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_logo(dir: &str, hash: &str, png: &[u8]) -> std::io::Result<()> {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let destination = format!("{dir}/{hash}.png");
|
||||||
|
if std::fs::read(&destination).is_ok_and(|bytes| bytes == png) { return Ok(()); }
|
||||||
|
let temporary = format!("{dir}/{}.tmp", uuid::Uuid::now_v7());
|
||||||
|
let result = std::fs::write(&temporary, png).and_then(|_| std::fs::rename(&temporary, destination));
|
||||||
|
if result.is_err() { let _ = std::fs::remove_file(temporary); }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logo_data(path: web::Path<(String, String)>) -> HttpResponse {
|
||||||
|
let (hash, file) = path.into_inner();
|
||||||
|
if hash.len() != 32 || !hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
|| file != format!("{hash}.png") || !custom_group::has_logo(&hash) {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
match std::fs::read(crate::runtime::get_data_path(&format!("custom_groups/{hash}.png"))) {
|
||||||
|
Ok(bytes) if format!("{:x}", md5::compute(&bytes)) == hash => HttpResponse::Ok()
|
||||||
|
.insert_header(("Cache-Control", "public, max-age=31536000, immutable"))
|
||||||
|
.content_type("image/png").body(bytes),
|
||||||
|
_ => HttpResponse::NotFound().finish(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(value: JsonValue) -> HttpResponse {
|
||||||
|
HttpResponse::Ok().insert_header(ContentType::json()).body(jzon::stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list() -> HttpResponse {
|
||||||
|
reply(object! { "result": "OK", "groups": custom_group::list() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(req: HttpRequest, body: String) -> HttpResponse {
|
||||||
|
if !can_manage(&req) {
|
||||||
|
return reply(object! { "result": "ERR", "message": "You do not have permission to create groups" });
|
||||||
|
}
|
||||||
|
let Ok(data) = jzon::parse(&body) else {
|
||||||
|
return reply(object! { "result": "ERR", "message": "Invalid JSON" });
|
||||||
|
};
|
||||||
|
let name = data["name"].as_str().unwrap_or("").trim();
|
||||||
|
let name_en = data["name_en"].as_str().unwrap_or("").trim();
|
||||||
|
if name.is_empty() || name.chars().count() > 60 || name_en.chars().count() > 60 {
|
||||||
|
return reply(object! { "result": "ERR", "message": "Group name must be 1-60 characters (English name at most 60)" });
|
||||||
|
}
|
||||||
|
if let Err(message) = rich_text::reject_tags("Group name", name, &[]) {
|
||||||
|
return reply(object! { "result": "ERR", "message": message });
|
||||||
|
}
|
||||||
|
if let Err(message) = rich_text::reject_tags("English group name", name_en, &[]) {
|
||||||
|
return reply(object! { "result": "ERR", "message": message });
|
||||||
|
}
|
||||||
|
match custom_group::create(name, name_en) {
|
||||||
|
Ok(id) => reply(object! { "result": "OK", "id": id }),
|
||||||
|
Err(_) => reply(object! { "result": "ERR", "message": "A group with that name already exists, or it could not be stored" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logo_preserves_aspect_and_transparency() {
|
||||||
|
let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(1024, 256, image::Rgba([1, 2, 3, 64])));
|
||||||
|
let mut input = Cursor::new(Vec::new());
|
||||||
|
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||||
|
let output = encode_logo(input.get_ref()).unwrap();
|
||||||
|
let decoded = image::load_from_memory(&output).unwrap().to_rgba8();
|
||||||
|
assert_eq!(decoded.dimensions(), (512, 128));
|
||||||
|
assert_eq!(decoded.get_pixel(0, 0).0[3], 64);
|
||||||
|
assert!(encode_logo(b"not an image").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_dimensions() {
|
||||||
|
let source = image::DynamicImage::new_rgba8(4097, 1);
|
||||||
|
let mut input = Cursor::new(Vec::new());
|
||||||
|
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||||
|
assert!(encode_logo(input.get_ref()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn logo_upload_requires_group_permission() {
|
||||||
|
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
|
||||||
|
let request = actix_web::test::TestRequest::post().uri("/custom_group/10000/logo").set_payload("invalid").to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn logo_catalog_and_content_addressed_download() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let id = custom_group::create(&format!("Logo test {}", uuid::Uuid::now_v7()), "").unwrap();
|
||||||
|
let mut input = Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::new_rgba8(32, 16).write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||||
|
let png = encode_logo(input.get_ref()).unwrap();
|
||||||
|
let hash = format!("{:x}", md5::compute(&png));
|
||||||
|
let dir = crate::runtime::get_data_path("custom_groups");
|
||||||
|
store_logo(&dir, &hash, &png).unwrap();
|
||||||
|
store_logo(&dir, &hash, &png).unwrap();
|
||||||
|
custom_group::set_logo(id, &hash, png.len() as i64).unwrap();
|
||||||
|
let catalog = custom_group::list();
|
||||||
|
let row = catalog.members().find(|g| g["id"].as_i64() == Some(id)).unwrap();
|
||||||
|
assert_eq!(row["logo_md5"].as_str(), Some(hash.as_str()));
|
||||||
|
assert_eq!(row["logo_size"].as_usize(), Some(png.len()));
|
||||||
|
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
|
||||||
|
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
|
||||||
|
let response = actix_web::test::call_service(&app, request).await;
|
||||||
|
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
|
||||||
|
assert_eq!(actix_web::test::read_body(response).await.as_ref(), png);
|
||||||
|
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/wrong.png")).to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
|
||||||
|
std::fs::write(format!("{dir}/{hash}.png"), b"corrupted").unwrap();
|
||||||
|
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
use crate::router::{global, rich_text, userdata, webui, Login, Api};
|
use crate::router::{global, rich_text, userdata, webui, Login, Api};
|
||||||
use crate::database::custom_song as database;
|
use crate::database::custom_song as database;
|
||||||
|
use crate::database::custom_group;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
use crate::lock_onto_mutex;
|
use crate::lock_onto_mutex;
|
||||||
|
|
||||||
@@ -39,6 +40,18 @@ use crate::lock_onto_mutex;
|
|||||||
// Shock.BAND_CATEGORY enum names
|
// Shock.BAND_CATEGORY enum names
|
||||||
const BAND_CATEGORIES: &[&str] = &["NONE", "MUSE", "AQOURS", "NIJIGAKU", "LIELLA", "HASUNOSORA", "OTHER", "YOHANE"];
|
const BAND_CATEGORIES: &[&str] = &["NONE", "MUSE", "AQOURS", "NIJIGAKU", "LIELLA", "HASUNOSORA", "OTHER", "YOHANE"];
|
||||||
|
|
||||||
|
fn selected_group_id(value: &str, band_category: &mut String) -> Result<i64, String> {
|
||||||
|
if value.is_empty() || value == "0" {
|
||||||
|
return Ok(database::band_group_id(band_category));
|
||||||
|
}
|
||||||
|
let id = value.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?;
|
||||||
|
if !custom_group::exists(id) {
|
||||||
|
return Err(String::from("Custom group does not exist"));
|
||||||
|
}
|
||||||
|
*band_category = String::from("OTHER");
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
// NORMAL, HARD, EXPERT, MASTER
|
// NORMAL, HARD, EXPERT, MASTER
|
||||||
const LEVEL_COUNT: i64 = 4;
|
const LEVEL_COUNT: i64 = 4;
|
||||||
const DEFAULT_LEVEL_NUMBERS: &[i64] = &[3, 6, 9, 12];
|
const DEFAULT_LEVEL_NUMBERS: &[i64] = &[3, 6, 9, 12];
|
||||||
@@ -129,13 +142,20 @@ pub fn client_supports_custom_songs(req: &HttpRequest) -> bool {
|
|||||||
|
|
||||||
// The catalog is filtered per requesting user: private songs only show for
|
// The catalog is filtered per requesting user: private songs only show for
|
||||||
// their owner, shared songs for the owner plus their shared-user list
|
// their owner, shared songs for the owner plus their shared-user list
|
||||||
async fn list(Login(key): Login) -> impl Responder {
|
async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||||
if disabled() {
|
if disabled() {
|
||||||
// As if the endpoint doesn't exist - the client treats this as feature-off
|
// As if the endpoint doesn't exist - the client treats this as feature-off
|
||||||
return Api(None);
|
return Api(None);
|
||||||
}
|
}
|
||||||
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
let mut songs = database::get_songs_for_user(uid);
|
let mut songs = database::get_songs_for_user(uid);
|
||||||
|
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
|
||||||
|
for song in songs.members_mut() {
|
||||||
|
if song["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
|
||||||
|
song["master_group_id"] = database::band_group_id("OTHER").into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for song in songs.members_mut() {
|
for song in songs.members_mut() {
|
||||||
// Additive field: the client turns it into the song's detail-info credit line (the
|
// Additive field: the client turns it into the song's detail-info credit line (the
|
||||||
// staff-credits text the live loading screen and the music library show). Old clients
|
// staff-credits text the live loading screen and the music library show). Old clients
|
||||||
@@ -149,6 +169,7 @@ async fn list(Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"revision": database::get_revision(),
|
"revision": database::get_revision(),
|
||||||
|
"groups": custom_group::list(),
|
||||||
"songs": songs
|
"songs": songs
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -649,6 +670,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
if !BAND_CATEGORIES.contains(&band_category.as_str()) {
|
if !BAND_CATEGORIES.contains(&band_category.as_str()) {
|
||||||
return Err(format!("Unknown band category '{}'", band_category));
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
}
|
}
|
||||||
|
let master_group_id = selected_group_id(&field_str(fields, "master_group_id"), &mut band_category)?;
|
||||||
|
|
||||||
validate_song_text(
|
validate_song_text(
|
||||||
&name,
|
&name,
|
||||||
@@ -746,6 +768,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"artist_en": field_str(fields, "artist_en"),
|
"artist_en": field_str(fields, "artist_en"),
|
||||||
"attribute": attribute,
|
"attribute": attribute,
|
||||||
"band_category": band_category.clone(),
|
"band_category": band_category.clone(),
|
||||||
|
"master_group_id": master_group_id,
|
||||||
"bpm": field_f64(fields, "bpm"),
|
"bpm": field_f64(fields, "bpm"),
|
||||||
"preview_start_sec": field_f64(fields, "preview_start_sec"),
|
"preview_start_sec": field_f64(fields, "preview_start_sec"),
|
||||||
"preview_length_sec": field_f64(fields, "preview_length_sec"),
|
"preview_length_sec": field_f64(fields, "preview_length_sec"),
|
||||||
@@ -761,7 +784,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"artist": artist,
|
"artist": artist,
|
||||||
"artist_en": field_str(fields, "artist_en"),
|
"artist_en": field_str(fields, "artist_en"),
|
||||||
"band_category": band_category.clone(),
|
"band_category": band_category.clone(),
|
||||||
"master_group_id": database::band_group_id(&band_category),
|
"master_group_id": master_group_id,
|
||||||
"attribute": attribute,
|
"attribute": attribute,
|
||||||
"bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32,
|
"bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32,
|
||||||
"start_wait": 2.0,
|
"start_wait": 2.0,
|
||||||
@@ -876,6 +899,13 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
if !BAND_CATEGORIES.contains(&band_category.as_str()) {
|
if !BAND_CATEGORIES.contains(&band_category.as_str()) {
|
||||||
return Err(format!("Unknown band category '{}'", band_category));
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
}
|
}
|
||||||
|
let group_text = if fields.contains_key("master_group_id") {
|
||||||
|
field_str(fields, "master_group_id")
|
||||||
|
} else {
|
||||||
|
old_manifest["master_group_id"].as_i64().filter(|id| *id >= custom_group::FIRST_ID)
|
||||||
|
.map(|id| id.to_string()).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let master_group_id = selected_group_id(&group_text, &mut band_category)?;
|
||||||
|
|
||||||
// The RESULTING text, so an edit that leaves a field alone is checked against what stays
|
// The RESULTING text, so an edit that leaves a field alone is checked against what stays
|
||||||
validate_song_text(
|
validate_song_text(
|
||||||
@@ -1023,6 +1053,7 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
"artist_en": text("artist_en"),
|
"artist_en": text("artist_en"),
|
||||||
"attribute": attribute,
|
"attribute": attribute,
|
||||||
"band_category": band_category.clone(),
|
"band_category": band_category.clone(),
|
||||||
|
"master_group_id": master_group_id,
|
||||||
"bpm": number("bpm"),
|
"bpm": number("bpm"),
|
||||||
"preview_start_sec": preview_start_sec,
|
"preview_start_sec": preview_start_sec,
|
||||||
"preview_length_sec": preview_length_sec,
|
"preview_length_sec": preview_length_sec,
|
||||||
@@ -1047,7 +1078,7 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
"artist": artist,
|
"artist": artist,
|
||||||
"artist_en": text("artist_en"),
|
"artist_en": text("artist_en"),
|
||||||
"band_category": band_category.clone(),
|
"band_category": band_category.clone(),
|
||||||
"master_group_id": database::band_group_id(&band_category),
|
"master_group_id": master_group_id,
|
||||||
"attribute": attribute,
|
"attribute": attribute,
|
||||||
"bpm": number("bpm").unwrap_or(DEFAULT_BPM) as f32,
|
"bpm": number("bpm").unwrap_or(DEFAULT_BPM) as f32,
|
||||||
"start_wait": 2.0,
|
"start_wait": 2.0,
|
||||||
@@ -2127,6 +2158,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_group_forces_other_band_and_rejects_unknown_ids() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
let id = custom_group::create(&format!("Test {}", unique), "Test Band").unwrap();
|
||||||
|
assert!(custom_group::exists(id));
|
||||||
|
let mut band = String::from("MUSE");
|
||||||
|
assert_eq!(selected_group_id(&id.to_string(), &mut band), Ok(id));
|
||||||
|
assert_eq!(band, "OTHER");
|
||||||
|
assert!(selected_group_id("999999999", &mut band).is_err());
|
||||||
|
assert_eq!(selected_group_id("", &mut band), Ok(9999));
|
||||||
|
}
|
||||||
|
|
||||||
// The whole feature is off unless --enable-custom-songs: endpoints 404 / go
|
// The whole feature is off unless --enable-custom-songs: endpoints 404 / go
|
||||||
// empty, and the webui config the client gates its nav on reports it off.
|
// empty, and the webui config the client gates its nav on reports it off.
|
||||||
// When enabled everything works.
|
// When enabled everything works.
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
|
|||||||
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Group IDs are server-local. The package records the original ID for
|
||||||
|
// reference, but only the destination upload form may choose its group;
|
||||||
|
// copying the ID could silently select a different band's row there.
|
||||||
for data in manifest["levels"].members() {
|
for data in manifest["levels"].members() {
|
||||||
let Some(level) = data["level"].as_i64() else { continue; };
|
let Some(level) = data["level"].as_i64() else { continue; };
|
||||||
if !data["level_number"].is_null() {
|
if !data["level_number"].is_null() {
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ fn init_star_event(event: &mut JsonValue) {
|
|||||||
|
|
||||||
async fn event(Session { key, body }: Session) -> impl Responder {
|
async fn event(Session { key, body }: Session) -> impl Responder {
|
||||||
let master_event_id = body["master_event_id"].as_u32().unwrap();
|
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 mut event = get_event_data(&key, master_event_id);
|
||||||
|
|
||||||
let is_star_event = STAR_EVENT_IDS.contains(&master_event_id);
|
let is_star_event = STAR_EVENT_IDS.contains(&master_event_id);
|
||||||
|
|||||||
@@ -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());
|
let pass = decrypt_transfer_password(&body["migration_password"].to_string());
|
||||||
|
|
||||||
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &pass);
|
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!{
|
let resp = object!{
|
||||||
result: "OK"
|
result: "OK"
|
||||||
|
|||||||
+14
-6
@@ -23,6 +23,10 @@ async fn preset(Session { key, body }: Session) -> impl Responder {
|
|||||||
}
|
}
|
||||||
userdata::save_acc_home(&key, user);
|
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![]))
|
Api(Some(array![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +88,20 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn home(Login(key): Login) -> impl Responder {
|
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);
|
let mut user = userdata::get_acc_home(&key);
|
||||||
|
|
||||||
check_gifts(&mut user);
|
check_gifts(&mut user);
|
||||||
|
|
||||||
let mut user_missions = userdata::get_acc_missions(&key);
|
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(&account, &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());
|
userdata::save_acc_home(&key, user.clone());
|
||||||
user["clear_mission_ids"] = clear;
|
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];
|
let daily_missions = array![1224003, 1253003, 1273009, 1273010, 1273011, 1273012];
|
||||||
|
|
||||||
@@ -128,8 +135,9 @@ async fn home(Login(key): Login) -> impl Responder {
|
|||||||
let new_announcement = crate::database::announcements::latest_published_at() > seen_at;
|
let new_announcement = crate::database::announcements::latest_published_at() > seen_at;
|
||||||
user["home"]["new_announcement_flag"] = (new_announcement as i32).into();
|
user["home"]["new_announcement_flag"] = (new_announcement as i32).into();
|
||||||
|
|
||||||
//todo
|
let (ready, complete) = super::beginner_mission::home_status(&user_missions);
|
||||||
user["home"]["beginner_mission_complete"] = 1.into();
|
user["home"]["clear_beginner_mission_count"] = ready.into();
|
||||||
|
user["home"]["beginner_mission_complete"] = (complete as i32).into();
|
||||||
|
|
||||||
Api(Some(user))
|
Api(Some(user))
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -545,8 +545,8 @@ pub fn completed_daily_mission(id: i64, missions: &mut JsonValue) -> JsonValue {
|
|||||||
let mut rv = array![];
|
let mut rv = array![];
|
||||||
if id == 1253003 {
|
if id == 1253003 {
|
||||||
rv = advance_variable_mission(1153001, 1153019, 1, missions);
|
rv = advance_variable_mission(1153001, 1153019, 1, missions);
|
||||||
if update_mission_status(1653001, 0, true, false, 1, missions).is_some() {
|
for id in super::beginner_mission::advance(53, None, 1, missions).members() {
|
||||||
rv.push(1653001).unwrap();
|
rv.push(id.clone()).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for mission in missions.members_mut() {
|
for mission in missions.members_mut() {
|
||||||
|
|||||||
@@ -727,6 +727,17 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
|
|||||||
|
|
||||||
//1273009, 1273010, 1273011, 1273012
|
//1273009, 1273010, 1273011, 1273012
|
||||||
let mut cleared_missions = items::advance_variable_mission(1105001, 1105017, 1, &mut user_missions);
|
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 {
|
if body["master_live_id"].to_string().len() > 1 {
|
||||||
let id = body["master_live_id"].to_string().split("").collect::<Vec<_>>()[2].parse::<i64>().unwrap_or(0);
|
let id = body["master_live_id"].to_string().split("").collect::<Vec<_>>()[2].parse::<i64>().unwrap_or(0);
|
||||||
if (1..=4).contains(&id) {
|
if (1..=4).contains(&id) {
|
||||||
@@ -822,6 +833,8 @@ 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 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);
|
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(&key, user.clone());
|
||||||
userdata::save_acc_home(&key, user2.clone());
|
userdata::save_acc_home(&key, user2.clone());
|
||||||
|
|||||||
@@ -390,6 +390,9 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
add_draw_count(&mut user, lottery_id, price_number);
|
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);
|
let mut new_count = get_draw_count(&user, lottery_id, price_number);
|
||||||
if is_stepup(lottery_id) && price_number == 1 {
|
if is_stepup(lottery_id) && price_number == 1 {
|
||||||
new_count += 1;
|
new_count += 1;
|
||||||
|
|||||||
+141
-7
@@ -12,6 +12,94 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn bond_title_claim_notifies_client_and_persists() {
|
||||||
|
let _test = crate::runtime::lock_test_data_path();
|
||||||
|
let (uid, key) = userdata::starter::create("Bond title test").unwrap();
|
||||||
|
let request = || actix_web::test::TestRequest::default()
|
||||||
|
.insert_header(("aoharu-user-id", uid.to_string())).to_http_request();
|
||||||
|
let initial_titles = userdata::get_acc(&key)["master_title_ids"].clone();
|
||||||
|
userdata::save_acc_missions(&key, array![object! {
|
||||||
|
master_mission_id: 1158015, status: 2, progress: 1500, expire_date_time: 0
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let response = receive(request(), Session {
|
||||||
|
key: key.clone(), body: object! {master_mission_ids: [1158015, 1158015]}
|
||||||
|
}).await.0.unwrap();
|
||||||
|
assert_eq!(response["reward_list"].len(), 1);
|
||||||
|
assert_eq!(response["reward_list"][0]["type"], 8);
|
||||||
|
assert_eq!(response["reward_list"][0]["value"], 3000015);
|
||||||
|
assert_eq!(response["updated_value_list"]["master_title_ids"], array![3000015]);
|
||||||
|
assert!(response["updated_value_list"]["item_list"].is_null());
|
||||||
|
let saved = userdata::get_acc(&key);
|
||||||
|
assert!(saved["master_title_ids"].contains(3000015));
|
||||||
|
for title in initial_titles.members() {
|
||||||
|
assert!(saved["master_title_ids"].contains(title.as_i64().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let replay = receive(request(), Session {
|
||||||
|
key: key.clone(), body: object! {master_mission_ids: [1158015]}
|
||||||
|
}).await.0.unwrap();
|
||||||
|
assert!(replay["reward_list"].is_empty());
|
||||||
|
assert!(replay["updated_value_list"]["master_title_ids"].is_null());
|
||||||
|
assert_eq!(userdata::get_acc(&key), saved);
|
||||||
|
|
||||||
|
let mut missions = userdata::get_acc_missions(&key);
|
||||||
|
items::update_mission_status(1158054, 0, true, false, 13500, &mut missions);
|
||||||
|
userdata::save_acc_missions(&key, missions);
|
||||||
|
let next = receive(request(), Session {
|
||||||
|
key: key.clone(), body: object! {master_mission_ids: [1158054]}
|
||||||
|
}).await.0.unwrap();
|
||||||
|
assert_eq!(next["updated_value_list"]["master_title_ids"], array![3000054]);
|
||||||
|
let saved = userdata::get_acc(&key);
|
||||||
|
assert!(saved["master_title_ids"].contains(3000015));
|
||||||
|
assert!(saved["master_title_ids"].contains(3000054));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i64> = 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]];
|
const VARIABLE_MISSIONS: [[i64; 2]; 5] = [[1153001, 1153019], [1105001, 1105017], [1101001, 1101030], [1121001, 1121019], [1112001, 1112033]];
|
||||||
|
|
||||||
async fn mission(Login(key): Login) -> impl Responder {
|
async fn mission(Login(key): Login) -> impl Responder {
|
||||||
@@ -25,9 +113,10 @@ async fn mission(Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut missions = userdata::get_acc_missions(&key);
|
let mut missions = userdata::get_acc_missions(&key);
|
||||||
if items::refresh_dailies(&mut missions, global::timestamp()) {
|
items::refresh_dailies(&mut missions, global::timestamp());
|
||||||
userdata::save_acc_missions(&key, missions.clone());
|
super::story::refresh(&user, &mut missions);
|
||||||
}
|
super::beginner_mission::refresh_account(&user, &mut missions);
|
||||||
|
userdata::save_acc_missions(&key, missions.clone());
|
||||||
|
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"mission_list": missions
|
"mission_list": missions
|
||||||
@@ -39,6 +128,10 @@ async fn clear(Session { key, body }: Session) -> impl Responder {
|
|||||||
|
|
||||||
let mut cleared = array![];
|
let mut cleared = array![];
|
||||||
for id in body["master_mission_ids"].members() {
|
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);
|
let mission = items::get_mission_status(id.as_i64().unwrap(), &missions);
|
||||||
if mission.is_empty() || mission["status"].as_i32().unwrap_or(0) >= 2 {
|
if mission.is_empty() || mission["status"].as_i32().unwrap_or(0) >= 2 {
|
||||||
continue;
|
continue;
|
||||||
@@ -54,7 +147,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 uid = global::get_uid(req.headers());
|
||||||
let now = global::timestamp();
|
let now = global::timestamp();
|
||||||
|
|
||||||
@@ -66,9 +163,21 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
|||||||
let mut touched_gem = false;
|
let mut touched_gem = false;
|
||||||
let mut touched_coin = false;
|
let mut touched_coin = false;
|
||||||
let mut touched_items = array![];
|
let mut touched_items = array![];
|
||||||
|
let mut touched_titles = std::collections::BTreeSet::new();
|
||||||
|
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() {
|
for mid in requested {
|
||||||
let mid = mission.as_i64().unwrap();
|
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();
|
let mission_info = databases::MISSION_LIST[mid.to_string()].clone();
|
||||||
for master in databases::MISSION_REWARDS[mission_info["masterMissionRewardId"].to_string()].members() {
|
for master in databases::MISSION_REWARDS[mission_info["masterMissionRewardId"].to_string()].members() {
|
||||||
let reward_type = master["type"].as_i64().unwrap();
|
let reward_type = master["type"].as_i64().unwrap();
|
||||||
@@ -90,6 +199,7 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
|||||||
match reward_type {
|
match reward_type {
|
||||||
1 => touched_gem = true,
|
1 => touched_gem = true,
|
||||||
4 => touched_coin = true,
|
4 => touched_coin = true,
|
||||||
|
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
|
||||||
_ => { touched_items.push(master["value"].clone()).unwrap(); }
|
_ => { touched_items.push(master["value"].clone()).unwrap(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,24 +245,45 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
|||||||
user_id: uid,
|
user_id: uid,
|
||||||
master_mission_id: mid,
|
master_mission_id: mid,
|
||||||
status: 3,
|
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 },
|
expire_date: if expire != 0 { global::format_datetime(expire).into() } else { JsonValue::Null },
|
||||||
received_date: global::format_datetime(now)
|
received_date: global::format_datetime(now)
|
||||||
}).unwrap();
|
}).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,
|
||||||
|
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
|
||||||
|
_ => { touched_items.push(master["value"].clone()).unwrap(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
userdata::save_acc(&key, user.clone());
|
userdata::save_acc(&key, user.clone());
|
||||||
userdata::save_acc_chats(&key, chats);
|
userdata::save_acc_chats(&key, chats);
|
||||||
userdata::save_acc_missions(&key, missions);
|
userdata::save_acc_missions(&key, missions);
|
||||||
|
|
||||||
let mut updated_value_list = object!{};
|
let mut updated_value_list = object!{};
|
||||||
|
if !touched_titles.is_empty() {
|
||||||
|
updated_value_list["master_title_ids"] = touched_titles.into_iter().collect::<Vec<_>>().into();
|
||||||
|
}
|
||||||
if touched_gem {
|
if touched_gem {
|
||||||
updated_value_list["gem"] = user["gem"].clone();
|
updated_value_list["gem"] = user["gem"].clone();
|
||||||
}
|
}
|
||||||
if !touched_items.is_empty() {
|
if !touched_items.is_empty() {
|
||||||
let mut item_list = array![];
|
let mut item_list = array![];
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
for id in touched_items.members() {
|
for id in touched_items.members() {
|
||||||
|
if !seen.insert(id.as_i64()) { continue; }
|
||||||
for item in user["item_list"].members() {
|
for item in user["item_list"].members() {
|
||||||
if item["master_item_id"] == *id {
|
if item["master_item_id"] == *id {
|
||||||
item_list.push(item.clone()).unwrap();
|
item_list.push(item.clone()).unwrap();
|
||||||
@@ -176,6 +307,9 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
|||||||
updated_value_list["point_list"] = point_list;
|
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!{
|
Api(Some(object!{
|
||||||
"reward_list": rewards,
|
"reward_list": rewards,
|
||||||
"gift_list": array![],
|
"gift_list": array![],
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder
|
|||||||
// live_end scales exp / gold / bond / mission rewards off.
|
// live_end scales exp / gold / bond / mission rewards off.
|
||||||
let mut end_body = body.clone();
|
let mut end_body = body.clone();
|
||||||
end_body["use_lp"] = lp_used.into();
|
end_body["use_lp"] = lp_used.into();
|
||||||
|
end_body["live_boost"] = live_boost.into();
|
||||||
if end_body["deck_slot"].is_null() {
|
if end_body["deck_slot"].is_null() {
|
||||||
if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) {
|
if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) {
|
||||||
end_body["deck_slot"] = slot.into();
|
end_body["deck_slot"] = slot.into();
|
||||||
|
|||||||
+249
-28
@@ -1,41 +1,262 @@
|
|||||||
use jzon::{object};
|
use jzon::{array, object, JsonValue};
|
||||||
use actix_web::{web, Responder};
|
use actix_web::{web, HttpRequest};
|
||||||
|
use crate::router::{chat, global, items, userdata, databases, Session, Api};
|
||||||
use crate::router::{userdata, databases, Session, Api};
|
use databases::csv::{table, Region};
|
||||||
|
|
||||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.route("/story/read", web::post().to(read));
|
cfg.route("/story/read", web::post().to(read));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read(Session { key, body }: Session) -> impl Responder {
|
static READ_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
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();
|
|
||||||
|
|
||||||
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() {
|
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) {
|
||||||
user["story_list"].push(object!{
|
let mut counts = std::collections::HashMap::<i64, i64>::new();
|
||||||
master_story_id: master_id,
|
for live in user["live_list"].members() {
|
||||||
master_story_part_ids: []
|
let song = &databases::MUSIC[live["master_live_id"].to_string()];
|
||||||
}).unwrap();
|
if !song.is_null() {
|
||||||
}
|
*counts.entry(song["bandCategory"].as_i64().unwrap_or(0)).or_default()
|
||||||
|
+= live["clear_count"].as_i64().unwrap_or(0);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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!{
|
async fn read(req: HttpRequest, Session { key, body }: Session) -> Api {
|
||||||
"gift_list":[],
|
let _read = crate::lock_onto_mutex!(READ_LOCK);
|
||||||
"updated_value_list":{
|
let Some(part_id) = body["master_story_part_id"].as_i64() else { return Api(None); };
|
||||||
"story_list": user["story_list"].clone()
|
let part = &databases::STORY[part_id.to_string()];
|
||||||
},
|
let Some(story_id) = part["masterStoryId"].as_i64() else { return Api(None); };
|
||||||
"reward_list":[],
|
let mut user = userdata::get_acc(&key);
|
||||||
"clear_mission_ids":[]
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-3
@@ -48,14 +48,17 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
userdata::save_acc(&key, user.clone());
|
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!{
|
Api(Some(object!{
|
||||||
"deck": {
|
"deck": {
|
||||||
"slot": body["slot"].clone(),
|
"slot": body["slot"].clone(),
|
||||||
"leader_role": 0,
|
"leader_role": 0,
|
||||||
"main_card_ids": body["main_card_ids"].clone()
|
"main_card_ids": body["main_card_ids"].clone()
|
||||||
},
|
},
|
||||||
"clear_mission_ids": []
|
"clear_mission_ids": cleared
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,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();
|
user["user"]["master_title_ids"][0] = body["master_title_ids"][0].clone();
|
||||||
}
|
}
|
||||||
if !body["birthday"].is_null() {
|
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());
|
userdata::save_acc(&key, user.clone());
|
||||||
@@ -181,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 {
|
pub async fn announcement(Login(key): Login) -> impl Responder {
|
||||||
|
|
||||||
let mut user = userdata::get_acc_home(&key);
|
let mut user = userdata::get_acc_home(&key);
|
||||||
@@ -212,6 +253,11 @@ async fn register_password(Session { key, body }: Session) -> impl Responder {
|
|||||||
let user = userdata::get_acc(&key);
|
let user = userdata::get_acc(&key);
|
||||||
|
|
||||||
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &body["pass"].to_string());
|
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![]))
|
Api(Some(array![]))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ pub fn get_acc(auth_key: &str) -> JsonValue {
|
|||||||
let mut user = get_data(auth_key, "userdata");
|
let mut user = get_data(auth_key, "userdata");
|
||||||
cleanup_account(&mut user);
|
cleanup_account(&mut user);
|
||||||
let mut changed = remove_deleted_custom_songs(&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) {
|
if remove_deleted_custom_cards(&mut user) {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -357,7 +363,9 @@ pub fn get_acc_home(auth_key: &str) -> JsonValue {
|
|||||||
user
|
user
|
||||||
}
|
}
|
||||||
pub fn get_acc_missions(auth_key: &str) -> JsonValue {
|
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 {
|
pub fn get_acc_loginbonus(auth_key: &str) -> JsonValue {
|
||||||
get_data(auth_key, "loginbonus")
|
get_data(auth_key, "loginbonus")
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
|||||||
scopes: permissions::get_user_permissions(uid),
|
scopes: permissions::get_user_permissions(uid),
|
||||||
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
|
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
|
||||||
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
|
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
|
||||||
|
can_manage_groups: permissions::has(uid, permissions::GROUP_MANAGE),
|
||||||
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
|
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
|
||||||
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
|
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
|
||||||
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|
||||||
|
|||||||
+1
-1
Submodule webui updated: 831d0c338a...86adb63496
Reference in New Issue
Block a user