31 Commits
Author SHA1 Message Date
Ethan O'Brien 78362608c8 Fix inability to set some options 2026-09-27 14:26:59 -05:00
Ethan O'Brien 8c58ff5b01 Bump version to 2.0.0 2026-09-27 14:06:23 -05:00
Ethan O'Brien 536e66811f Expose more settings 2026-09-27 14:05:08 -05:00
Ethan O'Brien 5a55bbf32a New assets 2026-09-27 12:52:05 -05:00
Ethan O'Brien 02afd15890 reorganize 2026-09-27 12:51:11 -05:00
Ethan O'Brien ee4ce5fed4 Remove chart migrate 2026-09-27 12:48:47 -05:00
Ethan O'Brien 9897a5d04a Cleanup compile warnings 2026-09-27 12:42:46 -05:00
Ethan O'Brien b1b70ab4c1 Don't send JP story IDs to the global client 2026-09-27 12:40:56 -05:00
Ethan O'Brien 99ba099d1b Add ability to force updates for outdated asset versions 2026-09-27 11:39:31 -05:00
Ethan O'Brien f1c8e96fbe Fix note count calculation 2026-09-26 22:19:16 -05:00
Ethan O'Brien abbfb6027a assets 2026-09-26 17:16:12 -05:00
Ethan O'Brien b38aec4ecf new assets 2026-09-26 13:49:19 -05:00
Ethan O'Brien 0b137effb5 Change some custom song handling stuff 2026-09-26 10:24:02 -05:00
Ethan O'Brien b08af4deb7 New asses 2026-09-26 09:01:08 -05:00
Ethan O'Brien bd6ae99888 sync webui 2026-09-25 22:48:55 -05:00
Ethan O'Brien 500159ecf5 New asset hashes 2026-09-25 22:00:24 -05:00
Ethan O'Brien 33eb7a4002 new webui 2026-09-25 20:13:39 -05:00
Ethan O'Brien 29bf71eb9b Redo story stuff 2026-09-25 12:06:46 -05:00
Ethan O'Brien 7ef418fc51 Fix updated titles not updating on reward recieve 2026-09-25 11:27:56 -05:00
Ethan O'Brien e387f1ff0e Restore beginner missions 2026-09-24 20:23:26 -05:00
Ethan O'Brien 125061863a Bump webui dependency 2026-09-24 19:17:41 -05:00
Ethan O'Brien dfc29c70e4 Add support for custom groups 2026-09-24 19:17:08 -05:00
Ethan O'Brien 8270662666 Keep one-point skill values valid under card nerf 2026-09-23 20:36:51 -05:00
Ethan O'Brien e258a8ca42 Add maintenance subcommand 2026-09-23 13:12:07 -05:00
Ethan O'Brien 3e58691c34 Fix custom card nerf not setting 2026-09-17 11:54:34 -05:00
Ethan O'Brien 2fe5ecd53e Fix issue 2026-09-16 22:17:31 -05:00
Ethan O'Brien 844a8f381b Add ability to nerf custom cards 2026-09-16 20:26:21 -05:00
Ethan O'Brien 49b2fbcb56 New asset hashes 2026-09-05 19:50:31 -05:00
Ethan O'Brien 78e1f1d1bb New asset hashes 2026-09-05 16:29:54 -05:00
Ethan O'Brien 2d9ea66ee3 Redo card syncing 2026-09-02 15:06:17 -05:00
Ethan O'Brien 59193d92f4 Ability to unbind nesica card 2026-09-02 10:09:24 -05:00
43 changed files with 2830 additions and 1219 deletions
Generated
+1 -1
View File
@@ -1085,7 +1085,7 @@ dependencies = [
[[package]] [[package]]
name = "ew" name = "ew"
version = "1.1.0" version = "2.0.0"
dependencies = [ dependencies = [
"actix-multipart", "actix-multipart",
"actix-web", "actix-web",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ew" name = "ew"
version = "1.1.0" version = "2.0.0"
edition = "2024" edition = "2024"
exclude = ["assets", "*.db"] exclude = ["assets", "*.db"]
+1 -1
View File
@@ -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" {
+2
View File
@@ -15,6 +15,7 @@ services:
DISABLE_EXPORTS: false # Will disable account exports DISABLE_EXPORTS: false # Will disable account exports
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints) #ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
#ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints) #ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
#NERF_CUSTOM_CARDS: true # Cap custom card stats below the official maxima and skills at 80% of the official range (new uploads rejected, existing cards clamped in responses); needs ENABLE_CUSTOM_CARDS
#ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints) #ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints)
#OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards #OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards
#PURGE: false # Purge dead user accounts on startup #PURGE: false # Purge dead user accounts on startup
@@ -31,6 +32,7 @@ services:
#EN_ANDROID_ASSET_HASH: "" #EN_ANDROID_ASSET_HASH: ""
#EN_IOS_ASSET_HASH: "" #EN_IOS_ASSET_HASH: ""
#WINDOWS_ASSET_HASH: "" # Windows asset hash (JP only; GL Windows is unused) #WINDOWS_ASSET_HASH: "" # Windows asset hash (JP only; GL Windows is unused)
#FORCE_UPDATES: "true" # Force outdated clients to update
# Everything below is for the "Help" page # Everything below is for the "Help" page
#ANDROID_GLOBAL: "link.to/patched/android/global.apk" #ANDROID_GLOBAL: "link.to/patched/android/global.apk"
+2
View File
@@ -15,8 +15,10 @@ args=(
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${NERF_CUSTOM_CARDS:-}" = "true" ] && args+=(--nerf-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv) [ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade) [ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
[ "${FORCE_UPDATES:-}" = "true" ] && args+=(--force-updates)
add_opt() { add_opt() {
local value="$1" flag="$2" local value="$1" flag="$2"
+1
View File
@@ -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;
+110 -117
View File
@@ -15,20 +15,18 @@ lazy_static! {
// rewritten from scratch at every credit. Neither is ever handed out twice and // rewritten from scratch at every credit. Neither is ever handed out twice and
// neither accumulates, so the arcade never leaves dead users behind. // neither accumulates, so the arcade never leaves dead users behind.
// //
// `cards` maps a physical card id to the account it plays as. `last_machine_id` // Which account a card names lives in userdata.db (userdata::user::migration,
// / `last_session` are the "which cabinet is this card sitting at" record: a // the `cards` table): linking a card is a plain account feature and works with
// card account is not owned by any one machine, so /live/end attributes its play // this module off. What lives here is only the cabinet side of a card:
// to the machine that most recently ran a session for that card. Keeping it as // `card_sessions` is the "which cabinet is this card sitting at" record -
// two columns on the row the session already writes is the whole design - no // `last_machine_id` / `last_session` attribute a card account's play to the
// second table, no row to expire, and the attribution can never outlive the // machine that most recently ran a session for it - and `session_until` is the
// mapping it belongs to. // one that costs money: the moment the credit that /api/arcade/session took
// // stops buying LP-free lives. Before it, a card account plays as a cabinet does;
// `session_until` is the same row's other half and the one that costs money: the // after it, the same account is an ordinary phone account again. It is a stamp
// moment the credit that /api/arcade/session took stops buying LP-free lives. // rather than a flag so that a cabinet that loses power mid-credit expires on
// Before it, a card account plays as a cabinet does; after it, the same account // its own with nothing to clean up, and so an operator can size the window with
// is an ordinary phone account again. It is a stamp rather than a flag so that a // --arcade-session-ttl. Re-linking a card clears its row (card_relinked).
// cabinet that loses power mid-credit expires on its own with nothing to clean
// up, and so an operator can size the window with --arcade-session-ttl.
// //
// `plays` is the bookkeeping ledger: one row per arcade song, cleared or not. // `plays` is the bookkeeping ledger: one row per arcade song, cleared or not.
// `cleared` is 0 for a song whose life gauge emptied: the client plays it out and // `cleared` is 0 for a song whose life gauge emptied: the client plays it out and
@@ -47,10 +45,8 @@ CREATE TABLE IF NOT EXISTS machines (
created BIGINT NOT NULL, created BIGINT NOT NULL,
last_seen BIGINT NOT NULL last_seen BIGINT NOT NULL
); );
CREATE TABLE IF NOT EXISTS cards ( CREATE TABLE IF NOT EXISTS card_sessions (
card_id TEXT NOT NULL PRIMARY KEY, card_id TEXT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
created BIGINT NOT NULL,
last_machine_id TEXT NOT NULL DEFAULT '', last_machine_id TEXT NOT NULL DEFAULT '',
last_session BIGINT NOT NULL DEFAULT 0, last_session BIGINT NOT NULL DEFAULT 0,
session_until BIGINT NOT NULL DEFAULT 0 session_until BIGINT NOT NULL DEFAULT 0
@@ -68,32 +64,35 @@ CREATE TABLE IF NOT EXISTS plays (
); );
CREATE INDEX IF NOT EXISTS plays_machine ON plays (machine_id); CREATE INDEX IF NOT EXISTS plays_machine ON plays (machine_id);
").unwrap(); ").unwrap();
// Upgrade databases written before the card session record existed. Existing // Databases from before the card mapping moved to userdata.db carry a
// mappings simply have no cabinet attached until their next session. // `cards` table here. Its mappings move over once, its windows become
if conn.prepare("SELECT last_machine_id FROM cards LIMIT 1;").is_err() { // card_sessions rows, and the table goes.
println!("Upgrading arcade card table"); if conn.prepare("SELECT user_id FROM cards LIMIT 1;").is_ok() {
conn.execute("ALTER TABLE cards ADD COLUMN last_machine_id TEXT NOT NULL DEFAULT '';", []).unwrap(); println!("Moving arcade card mappings to userdata");
conn.execute("ALTER TABLE cards ADD COLUMN last_session BIGINT NOT NULL DEFAULT 0;", []).unwrap(); let has_sessions = conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_ok();
let query = if has_sessions {
"SELECT card_id, user_id, created, last_machine_id, last_session, session_until FROM cards"
} else {
"SELECT card_id, user_id, created, '', 0, 0 FROM cards"
};
let mut stmt = conn.prepare(query).unwrap();
let rows: Vec<(String, i64, i64, String, i64, i64)> = stmt.query_map([], |row| Ok((
row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?
))).unwrap().flatten().collect();
drop(stmt);
for (card_id, user_id, created, last_machine_id, last_session, session_until) in rows {
crate::router::userdata::user::migration::import_card(&card_id, user_id, created);
if !last_machine_id.is_empty() || session_until != 0 {
conn.execute(
"INSERT OR REPLACE INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)",
params!(card_id, last_machine_id, last_session, session_until)
).unwrap();
} }
// Upgrade databases written before the LP-free window existed. 0 is "this
// card is not at a cabinet", so every existing mapping starts closed and
// opens at its next session - the safe direction.
if conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_err() {
println!("Upgrading arcade card table (session window)");
conn.execute("ALTER TABLE cards ADD COLUMN session_until BIGINT NOT NULL DEFAULT 0;", []).unwrap();
} }
// Upgrade databases written before failed songs were recorded at all. Every conn.execute("DROP TABLE cards;", []).unwrap();
// row already in the ledger got there through /live/end, which is a clear.
if conn.prepare("SELECT cleared FROM plays LIMIT 1;").is_err() {
println!("Upgrading arcade play table (cleared)");
conn.execute("ALTER TABLE plays ADD COLUMN cleared INTEGER NOT NULL DEFAULT 1;", []).unwrap();
} }
} }
// 16 hex characters, the shape the client stores in ArcadeSaveData.Machine and
// presents on every later call. It is the only thing that authenticates a
// cabinet, so it is drawn at full width rather than derived from anything
// guessable, and re-drawn on the (never observed) collision
pub fn generate_machine_id() -> String { pub fn generate_machine_id() -> String {
const CHARSET: &[u8] = b"0123456789abcdef"; const CHARSET: &[u8] = b"0123456789abcdef";
let mut rng = rand::rng(); let mut rng = rand::rng();
@@ -154,7 +153,7 @@ pub fn machine_of_account(user_id: i64) -> Option<JsonValue> {
pub fn delete_machine(machine_id: &str) { pub fn delete_machine(machine_id: &str) {
DATABASE.lock_and_exec("DELETE FROM plays WHERE machine_id=?1", params!(machine_id)); DATABASE.lock_and_exec("DELETE FROM plays WHERE machine_id=?1", params!(machine_id));
DATABASE.lock_and_exec("UPDATE cards SET last_machine_id='', last_session=0, session_until=0 WHERE last_machine_id=?1", params!(machine_id)); DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE last_machine_id=?1", params!(machine_id));
DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id)); DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id));
} }
@@ -218,41 +217,43 @@ pub fn machines_last_seen_before(cutoff: i64) -> JsonValue {
rv rv
} }
pub fn card_user(card_id: &str) -> Option<i64> {
DATABASE.lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
}
// Point a card at an account. `created` survives a re-bind: the card is the same
// physical object, only the account behind it changed. The cabinet record is
// cleared, because the previous holder's sessions say nothing about this one -
// and so is the LP-free window, which was bought for the previous account
pub fn set_card(card_id: &str, user_id: i64) {
DATABASE.lock_and_exec(
"INSERT INTO cards (card_id, user_id, created, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, '', 0, 0)
ON CONFLICT(card_id) DO UPDATE SET user_id=?2, last_machine_id='', last_session=0, session_until=0",
params!(card_id, user_id, global::timestamp() as i64)
);
}
// The cabinet this card is playing at right now and how long the credit it just // The cabinet this card is playing at right now and how long the credit it just
// paid buys LP-free lives for. Written by /api/arcade/session, and only there: // paid buys LP-free lives for. Written by /api/arcade/session, and only there:
// the session is the one moment the server knows a credit was taken // the session is the one moment the server knows a credit was taken
pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) { pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) {
DATABASE.lock_and_exec( DATABASE.lock_and_exec(
"UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4", "INSERT INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)
params!(machine_id, global::timestamp() as i64, until, card_id) ON CONFLICT(card_id) DO UPDATE SET last_machine_id=?2, last_session=?3, session_until=?4",
params!(card_id, machine_id, global::timestamp() as i64, until)
); );
} }
// The card of this account whose cabinet session is still open at `now`, as // A card that changed hands takes nothing of the previous holder's credit with it
// (card id, when the session started). None when no card of the account is at a pub fn clear_card_session(card_id: &str) {
// cabinet - which is every phone account, and every card between credits. DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE card_id=?1", params!(card_id));
pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { }
fn placeholders(count: usize) -> String {
(1..=count).map(|i| format!("?{}", i)).collect::<Vec<_>>().join(", ")
}
// Of these cards (an account's, userdata::user::migration::cards_of_account),
// the one whose cabinet session is still open at `now`, as (card id, when the
// session started). None when none of them is at a cabinet - which is every
// phone account, and every card between credits.
pub fn live_card_session(cards: &[String], now: i64) -> Option<(String, i64)> {
if cards.is_empty() {
return None;
}
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
let mut stmt = conn.prepare( let sql = format!(
"SELECT card_id, last_session FROM cards WHERE user_id=?1 AND session_until>?2 ORDER BY session_until DESC LIMIT 1" "SELECT card_id, last_session FROM card_sessions WHERE card_id IN ({}) AND session_until>?{} ORDER BY session_until DESC LIMIT 1",
).ok()?; placeholders(cards.len()), cards.len() + 1
stmt.query_row(params!(user_id, now), |row| Ok((row.get::<usize, String>(0)?, row.get::<usize, i64>(1)?))).ok() );
let mut stmt = conn.prepare(&sql).ok()?;
let mut args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
args.push(&now);
stmt.query_row(args.as_slice(), |row| Ok((row.get::<usize, String>(0)?, row.get::<usize, i64>(1)?))).ok()
} }
// A live starting inside the window pushes its end back, so a credit whose songs // A live starting inside the window pushes its end back, so a credit whose songs
@@ -261,7 +262,7 @@ pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> {
// ceiling would turn one credit into an endless supply of LP-free lives // ceiling would turn one credit into an endless supply of LP-free lives
pub fn extend_card_session(card_id: &str, until: i64) { pub fn extend_card_session(card_id: &str, until: i64) {
DATABASE.lock_and_exec( DATABASE.lock_and_exec(
"UPDATE cards SET session_until=?1 WHERE card_id=?2 AND session_until<?1", "UPDATE card_sessions SET session_until=?1 WHERE card_id=?2 AND session_until<?1",
params!(until, card_id) params!(until, card_id)
); );
} }
@@ -271,28 +272,31 @@ pub fn extend_card_session(card_id: &str, until: i64) {
#[cfg(test)] #[cfg(test)]
pub fn backdate_card_session_for_test(card_id: &str, last_session: i64, session_until: i64) { pub fn backdate_card_session_for_test(card_id: &str, last_session: i64, session_until: i64) {
DATABASE.lock_and_exec( DATABASE.lock_and_exec(
"UPDATE cards SET last_session=?1, session_until=?2 WHERE card_id=?3", "UPDATE card_sessions SET last_session=?1, session_until=?2 WHERE card_id=?3",
params!(last_session, session_until, card_id) params!(last_session, session_until, card_id)
); );
} }
// The machine that most recently ran a session for this account through any of // The machine that most recently ran a session for any of these cards. None
// its cards. Empty when the account has no card, or has never been at a cabinet // when none of them has ever been at a cabinet
pub fn last_machine_of_card_account(user_id: i64) -> Option<String> { pub fn last_machine_of_cards(cards: &[String]) -> Option<String> {
let machine_id: String = DATABASE.lock_and_select_type( if cards.is_empty() {
"SELECT last_machine_id FROM cards WHERE user_id=?1 AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1", return None;
params!(user_id) }
).ok()?; let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
let sql = format!(
"SELECT last_machine_id FROM card_sessions WHERE card_id IN ({}) AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1",
placeholders(cards.len())
);
let mut stmt = conn.prepare(&sql).ok()?;
let args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
let machine_id: String = stmt.query_row(args.as_slice(), |row| row.get(0)).ok()?;
if machine_id.is_empty() { if machine_id.is_empty() {
return None; return None;
} }
Some(machine_id) Some(machine_id)
} }
pub fn account_has_card(user_id: i64) -> bool {
DATABASE.lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok()
}
pub fn insert_play(machine_id: &str, user_id: i64, live_id: i64, level: i64, score: i64, rank: i64, cleared: bool) { pub fn insert_play(machine_id: &str, user_id: i64, live_id: i64, level: i64, score: i64, rank: i64, cleared: bool) {
DATABASE.lock_and_exec( DATABASE.lock_and_exec(
"INSERT INTO plays (machine_id, user_id, live_id, level, score, rank, at, cleared) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", "INSERT INTO plays (machine_id, user_id, live_id, level, score, rank, at, cleared) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -376,35 +380,29 @@ mod tests {
assert!(get_machine(&id).is_none()); assert!(get_machine(&id).is_none());
} }
// A card names an account; a re-bind repoints it and drops the previous // A card's cabinet record follows the card: it is attributed to the cabinet
// holder's cabinet record; plays are attributed to the cabinet the card last // the card last sat at, a re-link clears it, and it dies with the machine
// sat at and die with the machine
#[test] #[test]
fn a_card_names_an_account_and_its_last_cabinet() { fn a_cards_cabinet_record_follows_the_card() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
let machine = generate_machine_id(); let machine = generate_machine_id();
insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444); insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444);
let card = "0123456789012345"; let cards = vec!["0123456789012345".to_string()];
let card = cards[0].as_str();
assert!(card_user(card).is_none());
set_card(card, 555_555_555_555_555);
assert_eq!(card_user(card), Some(555_555_555_555_555));
assert!(account_has_card(555_555_555_555_555));
// Never at a cabinet yet // Never at a cabinet yet
assert!(last_machine_of_card_account(555_555_555_555_555).is_none()); assert!(last_machine_of_cards(&cards).is_none());
assert!(live_card_session(&[], 0).is_none());
let now = global::timestamp() as i64; let now = global::timestamp() as i64;
open_card_session(card, &machine, now + 60); open_card_session(card, &machine, now + 60);
assert_eq!(last_machine_of_card_account(555_555_555_555_555).as_deref(), Some(machine.as_str())); assert_eq!(last_machine_of_cards(&cards).as_deref(), Some(machine.as_str()));
// A re-bind keeps the card, moves the account and forgets the cabinet - // A re-link forgets the cabinet and the window the previous account's credit paid for
// and the window the previous account's credit paid for clear_card_session(card);
set_card(card, 666_666_666_666_666); assert!(last_machine_of_cards(&cards).is_none());
assert_eq!(card_user(card), Some(666_666_666_666_666)); assert!(live_card_session(&cards, now).is_none(), "a re-link carried the previous account's credit over");
assert!(!account_has_card(555_555_555_555_555));
assert!(last_machine_of_card_account(666_666_666_666_666).is_none());
assert!(live_card_session(666_666_666_666_666, now).is_none(), "a re-bind carried the previous account's credit over");
assert_eq!(play_count(&machine), 0); assert_eq!(play_count(&machine), 0);
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true); insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true);
@@ -421,53 +419,48 @@ mod tests {
assert_eq!(ledger[0]["score"].as_i64(), Some(123_456)); assert_eq!(ledger[0]["score"].as_i64(), Some(123_456));
assert_eq!(ledger[1]["cleared"].as_bool(), Some(true)); assert_eq!(ledger[1]["cleared"].as_bool(), Some(true));
// Removing the cabinet takes its ledger with it and unlinks the card // Removing the cabinet takes its ledger and its card records with it
open_card_session(card, &machine, now + 60); open_card_session(card, &machine, now + 60);
delete_machine(&machine); delete_machine(&machine);
assert_eq!(play_count(&machine), 0); assert_eq!(play_count(&machine), 0);
assert!(last_machine_of_card_account(666_666_666_666_666).is_none()); assert!(last_machine_of_cards(&cards).is_none());
assert!(live_card_session(666_666_666_666_666, now).is_none(), "a retired cabinet left a credit open"); assert!(live_card_session(&cards, now).is_none(), "a retired cabinet left a credit open");
assert_eq!(card_user(card), Some(666_666_666_666_666));
} }
// The credit a card paid for is a window on its own row: open until it is // The credit a card paid for is a window on its own row: open until it is
// not, pushed forward but never backward, and never shared with another card // not, pushed forward but never backward, and never shared with another card
// of another account
#[test] #[test]
fn a_credit_opens_a_window_that_closes_on_its_own() { fn a_credit_opens_a_window_that_closes_on_its_own() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
let machine = generate_machine_id(); let machine = generate_machine_id();
insert_machine(&machine, "Cabinet 3", 777_777_777_777_777, 888_888_888_888_888); insert_machine(&machine, "Cabinet 3", 777_777_777_777_777, 888_888_888_888_888);
let card = "1212121212121212"; let cards = vec!["1212121212121212".to_string()];
let user = 121_212_121_212_121; let card = cards[0].as_str();
let now = global::timestamp() as i64; let now = global::timestamp() as i64;
// A mapping with no session behind it is not a cabinet session // A card with no session behind it is not a cabinet session
set_card(card, user); assert!(live_card_session(&cards, now).is_none());
assert!(live_card_session(user, now).is_none());
open_card_session(card, &machine, now + 600); open_card_session(card, &machine, now + 600);
let (open_card, opened) = live_card_session(user, now).expect("the credit did not open a window"); let (open_card, opened) = live_card_session(&cards, now).expect("the credit did not open a window");
assert_eq!(open_card, card); assert_eq!(open_card, card);
assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now); assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now);
// The window closes by itself, with nothing to sweep // The window closes by itself, with nothing to sweep
assert!(live_card_session(user, now + 599).is_some()); assert!(live_card_session(&cards, now + 599).is_some());
assert!(live_card_session(user, now + 600).is_none(), "the window outlived its own expiry"); assert!(live_card_session(&cards, now + 600).is_none(), "the window outlived its own expiry");
assert!(live_card_session(user, now + 601).is_none()); assert!(live_card_session(&cards, now + 601).is_none());
// A live inside it pushes it forward, and only forward // A live inside it pushes it forward, and only forward
extend_card_session(card, now + 1200); extend_card_session(card, now + 1200);
assert!(live_card_session(user, now + 900).is_some()); assert!(live_card_session(&cards, now + 900).is_some());
extend_card_session(card, now + 300); extend_card_session(card, now + 300);
assert!(live_card_session(user, now + 900).is_some(), "an extension moved the window backwards"); assert!(live_card_session(&cards, now + 900).is_some(), "an extension moved the window backwards");
// Another account's card is untouched by any of it // Another card is untouched by any of it
let other_card = "3434343434343434"; let other = vec!["3434343434343434".to_string()];
let other_user = 343_434_343_434_343; assert!(live_card_session(&other, now).is_none());
set_card(other_card, other_user);
assert!(live_card_session(other_user, now).is_none());
delete_machine(&machine); delete_machine(&machine);
} }
+76
View File
@@ -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();
}
}
+2
View File
@@ -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
+6 -10
View File
@@ -1,4 +1,5 @@
mod static_handlers; mod static_handlers;
mod maintenance;
mod options; mod options;
mod router; mod router;
mod encryption; mod encryption;
@@ -19,8 +20,9 @@ mod ios;
use actix_web::{rt, App, HttpServer, web, dev::Service}; use actix_web::{rt, App, HttpServer, web, dev::Service};
//use actix_cors::Cors; //use actix_cors::Cors;
use std::time::Duration; use std::time::Duration;
pub use options::get_args; pub use options::{get_args, Commands};
use runtime::get_data_path; use runtime::get_data_path;
pub use maintenance::run as run_maintenance;
#[actix_web::main] #[actix_web::main]
pub async fn run_server(in_thread: bool) -> std::io::Result<()> { pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
@@ -29,24 +31,18 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
runtime::update_owners(&args.owner); runtime::update_owners(&args.owner);
runtime::set_nerf_custom_cards(args.nerf_custom_cards);
if args.purge { if args.purge {
println!("Purging accounts..."); println!("Purging accounts...");
// Cabinets first, so the accounts they take with them are gone before
// purge_accounts runs its VACUUM over the same database
let machines = crate::router::arcade::purge_machines(); let machines = crate::router::arcade::purge_machines();
if machines > 0 { println!("Purged {} arcade machines", machines);
println!("Purged {} arcade machine(s)", machines);
}
let ct = crate::router::userdata::purge_accounts(); let ct = crate::router::userdata::purge_accounts();
println!("Purged {} accounts", ct); println!("Purged {} accounts", ct);
} }
router::custom_song::migrate::run();
router::custom_song::sweep_audio(); router::custom_song::sweep_audio();
router::custom_3dmv::sweep_blobs(); router::custom_3dmv::sweep_blobs();
// The multi-live relay's expiry timers, on the system arbiter rather than on whichever
// HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not
// be able to strand every room's seats for the life of the process.
router::multi_live::start_sweeper(); router::multi_live::start_sweeper();
let rv = HttpServer::new(|| App::new() let rv = HttpServer::new(|| App::new()
+3
View File
@@ -2,6 +2,9 @@
#[cfg(not(feature = "library"))] #[cfg(not(feature = "library"))]
fn main() -> std::io::Result<()> { fn main() -> std::io::Result<()> {
let args = ew::get_args(); let args = ew::get_args();
if let Some(ew::Commands::Maintenance { message }) = args.command {
return ew::run_maintenance(args.port, message);
}
ew::runtime::update_data_path(&args.path); ew::runtime::update_data_path(&args.path);
ew::runtime::update_masterdata_path(&args.masterdata); ew::runtime::update_masterdata_path(&args.masterdata);
ew::run_server(false) ew::run_server(false)
+121
View File
@@ -0,0 +1,121 @@
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
use jzon::object;
#[actix_web::main]
pub async fn run(port: u16, message: String) -> std::io::Result<()> {
let message = web::Data::new(message);
let server = HttpServer::new(move || {
App::new()
.app_data(message.clone())
.default_service(web::route().to(respond))
})
.bind(("0.0.0.0", port))?
.run();
println!("Maintenance server listening on http://0.0.0.0:{port}");
server.await
}
async fn respond(req: HttpRequest, message: web::Data<String>) -> HttpResponse {
let message = message.get_ref().as_str();
if req.path() == "/maintenance/maintenance.json" {
// The title screen requires HTTP 200, server=false, and an active UTC window.
return HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.content_type("application/json")
.body(object! {
"opened_at": "1970-01-01 00:00:00",
"closed_at": "9999-01-01 00:00:00",
"message": message,
"server": false,
"gamelib": 0
}.dump());
}
let webui = req.path() == "/api/webui" || req.path().starts_with("/api/webui/");
let game_api = req.path() == "/api" || req.path().starts_with("/api/");
let browser = req.headers().get("Accept").and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("text/html"));
if req.headers().contains_key("aoharu-asset-version") || (game_api && !webui && !browser) {
// Do not use global::send: its per-user clock can access the database.
let body = object! {
"code": 10,
"server_time": crate::router::global::timestamp(),
"message": message
}.dump();
return HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.body(crate::encryption::encrypt_packet(&body).expect("packet encryption failed"));
}
let escaped = message.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
.replace('"', "&quot;").replace('\'', "&#39;");
HttpResponse::ServiceUnavailable()
.insert_header(("Cache-Control", "no-store"))
.content_type("text/html; charset=utf-8")
.body(include_str!("../web_assets/maintenance.html").replace("{{message}}", &escaped))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{http::{Method, StatusCode}, test};
const MESSAGE: &str = "Updating \"songs\" & <cards> — またね\nPlease wait.";
#[actix_web::test]
async fn title_check_is_active_and_preserves_message() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
let req = test::TestRequest::get().uri("/maintenance/maintenance.json?cache=1").to_request();
let response = test::call_service(&app, req).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store");
let body = test::read_body(response).await;
let json = jzon::parse(std::str::from_utf8(&body).unwrap()).unwrap();
assert_eq!(json["message"].as_str(), Some(MESSAGE));
assert_eq!(json["server"].as_bool(), Some(false));
let now = crate::router::global::format_datetime(crate::router::global::timestamp());
assert!(json["opened_at"].as_str().unwrap() < now.as_str());
assert!(json["closed_at"].as_str().unwrap() > now.as_str());
}
#[actix_web::test]
async fn game_requests_get_encrypted_maintenance_without_reading_the_body() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
for (path, header) in [("/api/start", false), ("/api/live/end", true),
("/api/unknown", false), ("/v1.0/test", true), ("/anything", true)] {
let mut req = test::TestRequest::post().uri(path)
.insert_header(("aoharu-user-id", "12345")).set_payload("not an encrypted request");
if header { req = req.insert_header(("aoharu-asset-version", "old")); }
let response = test::call_service(&app, req.to_request()).await;
assert_eq!(response.status(), StatusCode::OK, "{path}");
let body = test::read_body(response).await;
let decoded = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
let json = jzon::parse(&decoded).unwrap();
assert_eq!(json["code"].as_i32(), Some(10));
assert_eq!(json["message"].as_str(), Some(MESSAGE));
}
}
#[actix_web::test]
async fn all_other_routes_and_methods_are_maintenance_only() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
for path in ["/", "/maintenance.html", "/custom_song/upload", "/custom_card/delete",
"/custom_3dmv/upload", "/announcement/create", "/api/webui/login",
"/Android/hash/file", "/v1.0/test", "/api/user", "/unknown"] {
for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] {
let response = test::call_service(&app,
test::TestRequest::default().method(method).uri(path)
.insert_header(("Accept", "text/html")).to_request()).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
let body = test::read_body(response).await;
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains("&lt;cards&gt;"));
assert!(html.contains("またね"));
assert!(!html.contains("<cards>"));
}
}
}
}
+47 -2
View File
@@ -1,9 +1,12 @@
use clap::Parser; use clap::{Parser, Subcommand};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Args { pub struct Args {
#[arg(short, long, default_value_t = 8080, help = "Port to listen on")] #[command(subcommand)]
pub command: Option<Commands>,
#[arg(short, long, global = true, default_value_t = 8080, help = "Port to listen on")]
pub port: u16, pub port: u16,
#[arg(long, default_value = "./", help = "Path to store database files")] #[arg(long, default_value = "./", help = "Path to store database files")]
@@ -44,6 +47,9 @@ pub struct Args {
#[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")]
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
#[arg(long, default_value_t = false, help = "Nerf custom cards: cap base stats below the official per-rarity maxima (median band) and skill values at 80% of the official range. Applies at upload time and to cards already uploaded, which are clamped in every response")]
pub nerf_custom_cards: bool,
#[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")] #[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
pub enable_custom_3dmv: bool, pub enable_custom_3dmv: bool,
@@ -83,6 +89,12 @@ pub struct Args {
#[arg(long, default_value = "", help = "Asset version for overridden asset hashes.")] #[arg(long, default_value = "", help = "Asset version for overridden asset hashes.")]
pub asset_version: String, pub asset_version: String,
#[arg(long, default_value = "", help = "Global asset version for overridden asset hashes.")]
pub en_asset_version: String,
#[arg(long, default_value_t = false, help = "Force an application update for asset versions not marked as latest")]
pub force_updates: bool,
#[arg(long, default_value = "", help = "Asset hash for windows client.")] #[arg(long, default_value = "", help = "Asset hash for windows client.")]
pub windows_asset_hash: String, pub windows_asset_hash: String,
@@ -99,8 +111,41 @@ pub struct Args {
pub masterdata: String pub masterdata: String
} }
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Start a maintenance-only server until stopped
Maintenance {
/// The message shown to the client
message: String,
},
}
pub fn get_args() -> Args { pub fn get_args() -> Args {
#[cfg(not(test))]
let mut args = Args::parse(); let mut args = Args::parse();
#[cfg(test)]
let mut args = Args::parse_from(["ew"]);
crate::runtime::overlay_args(&mut args); crate::runtime::overlay_args(&mut args);
args args
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maintenance_command_and_port() {
for argv in [
vec!["ew", "--port", "6017", "maintenance", "Updating songs"],
vec!["ew", "maintenance", "Updating songs", "--port", "6017"],
] {
let args = Args::try_parse_from(argv).unwrap();
assert_eq!(args.port, 6017);
assert!(matches!(args.command, Some(Commands::Maintenance { message }) if message == "Updating songs"));
}
assert!(Args::try_parse_from(["ew", "maintenance"]).is_err());
let normal = Args::try_parse_from(["ew"]).unwrap();
assert!(normal.command.is_none());
assert_eq!(normal.port, 8080);
}
}
+7 -2
View File
@@ -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;
@@ -190,7 +192,7 @@ fn not_found(req: &HttpRequest) -> HttpResponse {
global::send(rv, 0, req) global::send(rv, 0, req)
} }
// Fallback for paths no actix route matched. Game endpoints live in each module's routes() // Fallback
async fn api_req(req: HttpRequest, body: String) -> HttpResponse { async fn api_req(req: HttpRequest, body: String) -> HttpResponse {
let args = crate::get_args(); let args = crate::get_args();
if args.hidden && (req.path().starts_with("/api/webui/") || !(req.path().starts_with("/api") || req.path().starts_with("/v1.0"))) { if args.hidden && (req.path().starts_with("/api/webui/") || !(req.path().starts_with("/api") || req.path().starts_with("/v1.0"))) {
@@ -225,7 +227,8 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/grantPermission" => webui::grant_permission(req, body), "/api/webui/grantPermission" => webui::grant_permission(req, body),
"/api/webui/revokePermission" => webui::revoke_permission(req, body), "/api/webui/revokePermission" => webui::revoke_permission(req, body),
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body), "/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body), "/api/webui/linkNesicaCard" => webui::link_nesica_card(req, body),
"/api/webui/unlinkNesicaCard" => webui::unlink_nesica_card(req, body),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} else { } else {
@@ -246,6 +249,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
"/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/myScopes" => webui::my_scopes(req),
"/api/webui/listArcadeMachines" => webui::list_arcade_machines(req), "/api/webui/listArcadeMachines" => webui::list_arcade_machines(req),
"/api/webui/listNesicaCards" => webui::list_nesica_cards(req),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} }
@@ -299,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);
} }
+79 -477
View File
@@ -1,46 +1,15 @@
// Arcade mode: a SIF2 client turned into a rhythm cabinet. // Arcade mode: a SIF2 client turned into a rhythm cabinet.
//
// A cabinet registers once and gets a machine id plus two accounts it owns
// forever - the MACHINE account (the identity the attract loop and its demo
// lives run on) and one reusable GUEST. Every credit calls /session; without a
// card that rewrites the guest back to the starter state in place, keeping its
// user id but re-drawing everything a player could have left on it - its login
// token included - so the machine plays a clean account and the server never
// accumulates dead users. With a card, the card names the account and the play
// is real progress on it.
//
// Credits replace LP: a live flagged `arcade` runs through live_end_ex with
// consume_lp = false - the seam /multi_live/end already uses - and its use_lp
// pinned to one normal play, so rewards, EXP, bonds, high scores and clears all
// record exactly as they do on a phone while LP is never touched.
//
// That flag is money, so it is not taken on trust. It buys a free play only for
// a machine's own two identities, or for a card account inside the window the
// credit at /api/arcade/session opened for it, and only when the /live/start
// this /live/end answers was itself flagged. See arcade_account_at.
//
// The whole feature is opt-in (--enable-arcade) and additionally off in
// --hidden mode. When disabled every endpoint answers like the custom-song
// endpoints do with their flag off - Api(None), as if it never existed - and
// nothing touches arcade.db, so no table setup runs.
use jzon::{object, JsonValue}; use jzon::{object, JsonValue};
use actix_web::{web, Responder}; use actix_web::{web, Responder};
use crate::router::{databases, global, live, multi_live, userdata, Api, Body}; use crate::router::{databases, global, live, multi_live, userdata, Api, Body};
use crate::router::userdata::user::migration;
use crate::database::arcade as database; use crate::database::arcade as database;
// The name a cabinet falls back to when its operator sent nothing usable
const DEFAULT_MACHINE_NAME: &str = "ARCADE"; const DEFAULT_MACHINE_NAME: &str = "ARCADE";
// Guest accounts are created under this name and renamed to their cabinet's own
// name at the first session (design 4.3: a credit plays as the machine)
const GUEST_NAME: &str = "GUEST"; const GUEST_NAME: &str = "GUEST";
// NESiCA ids are 16 ASCII digits; the cap is generous enough for any other
// reader an I/O provider might present without letting an id become a blob
const MAX_CARD_ID_LEN: usize = 32;
pub fn routes(cfg: &mut web::ServiceConfig) { pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/arcade") web::scope("/arcade")
@@ -56,114 +25,43 @@ pub fn disabled() -> bool {
args.hidden || !args.enable_arcade args.hidden || !args.enable_arcade
} }
// Days a machine may go unseen before --purge deletes it. 0 means never. // 0 = forever
fn machine_ttl_days() -> u64 { fn machine_ttl_days() -> u64 {
crate::get_args().arcade_machine_ttl crate::get_args().arcade_machine_ttl
} }
// How long one credit buys LP-free play for the card that paid it. Long enough
// that a slow credit never runs out mid-song (the design's play is two songs),
// short enough that a card left on a reader overnight is not an open tap.
fn session_ttl() -> i64 { fn session_ttl() -> i64 {
crate::get_args().arcade_session_ttl as i64 * 60 crate::get_args().arcade_session_ttl as i64 * 60
} }
// A live played inside the window pushes it back, so a credit that runs long is
// never cut off - but a credit is a credit, and past this many windows from the
// session that opened it the extension stops. Without a ceiling one tap of a
// card would buy LP-free lives for as long as the player kept starting them.
const MAX_SESSION_WINDOWS: i64 = 4; const MAX_SESSION_WINDOWS: i64 = 4;
// The client writes its request-body flags as 0/1 ints (auto_play, is_omakase,
// ...), so `arcade` is read as either that or a JSON bool.
fn flag(value: &JsonValue) -> bool { fn flag(value: &JsonValue) -> bool {
value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0 value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0
} }
// A card id is an identifier, never text: it is refused rather than sanitised,
// because a mangled id would silently name a different card's account.
fn card_id(body: &JsonValue) -> Option<String> { fn card_id(body: &JsonValue) -> Option<String> {
let card_id = body["card_id"].as_str().unwrap_or("").trim().to_string(); migration::valid_card_id(body["card_id"].as_str().unwrap_or(""))
if card_id.is_empty() { }
return None;
pub fn is_cabinet_account(user_id: i64) -> bool {
!disabled() && database::machine_of_account(user_id).is_some()
}
pub fn card_relinked(card: &str) {
if !disabled() {
database::clear_card_session(card);
} }
if card_id.len() > MAX_CARD_ID_LEN || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) { }
return None;
} fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> {
Some(card_id) database::live_card_session(&migration::cards_of_account(user_id), now)
} }
// The credit is taken: this card is at this cabinet, and for the next ttl it
// plays the way a cabinet plays. The single place a window is ever opened -
// /api/arcade/session is the one moment the server is told a credit was spent.
fn open_card_session(card: &str, machine_id: &str) { fn open_card_session(card: &str, machine_id: &str) {
database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl()); database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl());
} }
// An account made for a card nobody has named yet takes the card's last four
// digits, the way a cabinet prints them on a receipt. The on-cabinet name entry
// overwrites it.
fn card_account_name(card_id: &str) -> String {
let start = card_id.len().saturating_sub(4);
card_id[start..].to_string()
}
// The account this card was issued seconds ago, and which nothing has happened
// to since - the only account /api/arcade/session will ever rename.
//
// The cabinet's name entry is a second /session for the same card: the first one
// created the account and answered is_new, the player typed a name, and this is
// the same call again carrying it. By then the card is KNOWN, so the rename has
// to be told apart from an ordinary card session, and it has to be told apart
// hard: a rename that reached a real account would let anyone holding a card id
// retitle a stranger's save. Every clause below has to hold.
//
// Deliberately not a `renamed` column: the account itself carries the proof.
fn issued_by_this_credit(card: &str, user_id: i64, now: i64, ttl: i64) -> bool {
// The credit that created it is still running, at this card. live_card_session
// is the window /api/arcade/session opened and nothing else ever opens
// (database/arcade.rs:240), so this is "the same credit, still going". `ttl`
// is session_ttl() in production and the test's own number in the test - the
// shape purge_machines_before and retire_user_at already use here.
let Some((open_card, opened)) = database::live_card_session(user_id, now) else {
return false;
};
if open_card != card || now - opened > ttl {
return false;
}
// A cabinet's own machine or guest identity is never a card's account
if database::machine_of_account(user_id).is_some() {
return false;
}
// A phone has owned it - the same test bind_card's cleanup trusts
if userdata::has_transfer_password(user_id) {
return false;
}
let user = userdata::get_acc_from_uid(user_id);
if user["error"].as_bool().unwrap_or(false) {
return false;
}
// It still carries the placeholder /session gave it, and nothing has been
// played on it. Either alone would be enough to make a rename harmless; both
// together make it provable.
if user["user"]["name"].as_str().unwrap_or("") != card_account_name(card) {
return false;
}
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
}
// The one write a rename is. /api/user does exactly this for a phone player
// (user.rs:138-140); there is no other name in an account.
fn rename_account(user_id: i64, login_token: &str, name: &str) {
let mut user = userdata::get_acc_from_uid(user_id);
user["user"]["name"] = name.into();
userdata::save_acc(login_token, user);
}
// -- endpoints --------------------------------------------------------------
// The client asks this before it offers to convert a device: a server without
// the module answers None and the Title-menu entry refuses.
async fn info() -> impl Responder { async fn info() -> impl Responder {
if disabled() { if disabled() {
return Api(None); return Api(None);
@@ -174,8 +72,6 @@ async fn info() -> impl Responder {
})) }))
} }
// Converting a fresh device into a cabinet. Pre-login by nature: the device has
// no account yet, which is exactly the state the Title-menu entry requires.
async fn register(Body(body): Body) -> impl Responder { async fn register(Body(body): Body) -> impl Responder {
if disabled() { if disabled() {
return Api(None); return Api(None);
@@ -186,8 +82,6 @@ async fn register(Body(body): Body) -> impl Responder {
return Api(None); return Api(None);
}; };
let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else { let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else {
// Half a cabinet is worse than none: the machine account has nothing
// pointing at it and nobody holding its token, so it goes back.
userdata::delete_account(machine_user_id); userdata::delete_account(machine_user_id);
return Api(None); return Api(None);
}; };
@@ -205,14 +99,17 @@ async fn register(Body(body): Body) -> impl Responder {
})) }))
} }
// One credit. Answers the account the player is about to become: the cabinet's fn resolve_card(card: &str) -> Option<(i64, String)> {
// guest (reset in place) or, with a card, the account that card names. let user_id = migration::card_user(card)?;
// let uuid = userdata::get_login_token(user_id);
// `name` is the cabinet's on-screen name entry, and it is read in exactly two if uuid.is_empty() {
// places: the account a brand new card is issued, and a rename of that same println!("arcade: card mapping pointed at missing account {} - unlinking the card", user_id);
// account while the credit that created it is still running (see migration::remove_card(card);
// issued_by_this_credit). A guest credit ignores it - a guest is named after its return None;
// cabinet - and so does an ordinary card session. }
Some((user_id, uuid))
}
async fn session(Body(body): Body) -> impl Responder { async fn session(Body(body): Body) -> impl Responder {
if disabled() { if disabled() {
return Api(None); return Api(None);
@@ -225,25 +122,14 @@ async fn session(Body(body): Body) -> impl Responder {
database::touch_machine(&machine_id); database::touch_machine(&machine_id);
let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string(); let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string();
// A guest credit is a session with no card_id at all (every credit in v1) or
// an empty one. Anything else is a card being presented, and a card id that
// does not parse is refused rather than quietly played as a guest on
// somebody's credit.
let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty(); let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty();
let card = card_id(&body); let card = card_id(&body);
if presented && card.is_none() { if presented && card.is_none() {
println!("arcade: machine {} presented an unusable card id", machine_id); println!("arcade: machine {} presented an unusable card id", machine_id);
return Api(None); return Api(None);
} }
let typed_name = body["name"].as_str().unwrap_or("").trim().to_string();
let Some(card) = card else { let Some(card) = card else {
// The cabinet's own guest, rewritten from scratch. Same user id, so the
// machine keeps its one guest forever - but a new login token, because
// the previous player had a credit's worth of time alone with the old
// one. The client adopts the uuid this answer carries (ArcadeEntranceScene
// OnSessionResponse -> MngArcadeData.AdoptIdentity), so the rotation is
// invisible to it.
let guest_user_id = machine["guest_user_id"].as_i64().unwrap_or(0); let guest_user_id = machine["guest_user_id"].as_i64().unwrap_or(0);
let Some(uuid) = userdata::starter::reset(guest_user_id, &machine_name) else { let Some(uuid) = userdata::starter::reset(guest_user_id, &machine_name) else {
println!("arcade: machine {} has no guest account to reset", machine_id); println!("arcade: machine {} has no guest account to reset", machine_id);
@@ -253,104 +139,34 @@ async fn session(Body(body): Body) -> impl Responder {
"user_id": guest_user_id, "user_id": guest_user_id,
"uuid": uuid, "uuid": uuid,
"name": machine_name, "name": machine_name,
"is_new": true "unlinked": false
})); }));
}; };
// A known card plays its own account, with every clear it has ever earned // A known card plays its own account, with every clear it has ever earned
if let Some(user_id) = database::card_user(&card) { let Some((user_id, uuid)) = resolve_card(&card) else {
let uuid = userdata::get_login_token(user_id); println!("arcade: machine {} presented a card that is linked to no account", machine_id);
if !uuid.is_empty() { return Api(Some(object!{ "unlinked": true }));
// The cabinet's name entry, coming back for the account this credit };
// just made. Checked BEFORE the window is re-opened, because the
// proof is the window the previous call opened.
if !typed_name.is_empty() && issued_by_this_credit(&card, user_id, global::timestamp() as i64, session_ttl()) {
let named = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
rename_account(user_id, &uuid, &named);
println!("arcade: machine {} named its new card's account {} \"{}\"", machine_id, user_id, named);
}
open_card_session(&card, &machine_id); open_card_session(&card, &machine_id);
let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string(); let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string();
return Api(Some(object!{
"user_id": user_id,
"uuid": uuid,
"name": name,
"is_new": false
}));
}
// The mapping outlived its account (deleted through the webui, or aged
// out with a machine). The card is not broken: it gets a new account.
println!("arcade: card mapping pointed at missing account {} - reissuing", user_id);
}
// A card nobody has seen before. It gets an account named after its own last
// four digits unless the cabinet already has a name for it - which it only
// does when something other than the entrance's own two-step asked for one.
let name = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
let Some((user_id, uuid)) = userdata::starter::create(&name) else {
return Api(None);
};
database::set_card(&card, user_id);
open_card_session(&card, &machine_id);
println!("arcade: machine {} issued account {} to a new card", machine_id, user_id);
Api(Some(object!{ Api(Some(object!{
"user_id": user_id, "user_id": user_id,
"uuid": uuid, "uuid": uuid,
"name": name, "name": name,
"is_new": true "unlinked": false
})) }))
} }
// Point a card at a player account the caller has already identified. This is
// the rule every entrance shares - the card id is validated, a cabinet's own
// identities are refused, the mapping is replaced and the throwaway account
// the card was carrying is cleaned up - and the proof of *whose* account it is
// belongs to the caller: the cabinet's /api/arcade/bind takes the game's
// data-transfer code and password (bind_card), the webui account page takes
// the signed-in session itself, which already proves the account.
pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> { pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> {
if disabled() { if disabled() {
return Err(String::from("Arcade mode is disabled on this server")); return Err(String::from("Arcade mode is disabled on this server"));
} }
let Some(card) = card_id(&object!{ "card_id": card }) else { migration::link_card(card, user_id)?;
return Err(String::from("That is not a usable card id"));
};
// A cabinet's own two identities are not player accounts and may never be
// behind a card. The guest in particular is rewritten for a stranger every
// credit: a card pointing at it would outlive that reset, and /session hands
// out the account's current login token to whoever presents the card. Every
// proof a bind can take - a transfer code and password, a webui login - is
// exactly what a hostile client can register on a guest during its own
// credit, so the refusal lives here, below all of them.
if database::machine_of_account(user_id).is_some() {
return Err(String::from("That account belongs to an arcade cabinet"));
}
let previous = database::card_user(&card);
database::set_card(&card, user_id);
// A card that was carrying a throwaway account the cabinet made for it takes
// that account with it. Anything the player might care about keeps the card
// from taking it: see orphan_of_a_rebound_card.
if let Some(previous) = previous
&& previous != user_id
&& orphan_of_a_rebound_card(previous) {
println!("arcade: card {} left empty account {} behind - removing", card, previous);
userdata::delete_account(previous);
}
println!("arcade: card {} now plays as account {}", card, user_id); println!("arcade: card {} now plays as account {}", card, user_id);
Ok(user_id) Ok(user_id)
} }
// The cabinet's bind: the proof that a phone account is the player's is the
// game's own data-transfer code and password. On a phone the transfer runs
// through GREE's native code, and ew's /api/user/gglverifymigrationcode is only
// the desktop route with GGL off; what both share is the `migration` table -
// the code and the hashed password - which get_acc_transfer is the one owner
// of. A cabinet is a Windows build with GGL off, so that pair is its legitimate
// path, typed in from the game's Data Transfer screen.
pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> { pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> {
if disabled() { if disabled() {
return Err(String::from("Arcade mode is disabled on this server")); return Err(String::from("Arcade mode is disabled on this server"));
@@ -379,50 +195,8 @@ async fn bind(Body(body): Body) -> impl Responder {
} }
} }
// An account /api/arcade/session issued to an unknown card that was then never // lives
// used for anything. Every one of these has to hold for it to be thrown away
// with the card, because a false positive deletes somebody's save:
// * no other card still points at it,
// * it is not a cabinet's own machine or guest identity,
// * it has never registered a transfer password, so no phone ever owned it,
// * and it has no live record at all - nothing was ever played on it.
fn orphan_of_a_rebound_card(user_id: i64) -> bool {
if database::account_has_card(user_id) {
return false;
}
if database::machine_of_account(user_id).is_some() {
return false;
}
if userdata::has_transfer_password(user_id) {
return false;
}
let user = userdata::get_acc_from_uid(user_id);
if user["error"].as_bool().unwrap_or(false) {
return false;
}
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
}
// -- lives ------------------------------------------------------------------
// The account playing on a cabinet right now, None when the `arcade` flag is
// just a flag in a request body.
//
// The flag decides whether the play costs LP, so it is honoured for exactly two
// kinds of account:
//
// * one of a machine's own two identities. The cabinet holds both tokens, the
// guest is rewritten from scratch at every credit and the machine account
// only ever runs the attract loop, so a free play on either buys nobody
// anything.
// * an account a card is bound to, and only while the credit that card paid
// for is still running: /api/arcade/session opened a window on the card row
// and it has not closed yet.
//
// A card mapping on its own proves nothing - a player can bind a card to their
// own account from the webui account page without ever standing in front of a
// cabinet - so an account whose card is not at a machine right now is an
// ordinary phone account and pays LP, exactly as before.
fn cabinet_account_at(login_token: &str, now: i64) -> Option<i64> { fn cabinet_account_at(login_token: &str, now: i64) -> Option<i64> {
let user_id = userdata::uid_from_login_token(login_token); let user_id = userdata::uid_from_login_token(login_token);
if user_id == 0 { if user_id == 0 {
@@ -431,12 +205,9 @@ fn cabinet_account_at(login_token: &str, now: i64) -> Option<i64> {
if database::machine_of_account(user_id).is_some() { if database::machine_of_account(user_id).is_some() {
return Some(user_id); return Some(user_id);
} }
database::live_card_session(user_id, now).map(|_| user_id) live_card_session(user_id, now).map(|_| user_id)
} }
// The same account rule behind the request's own opt-in flag, which /live/start
// and /live/end carry. The flag is read first so a phone play never reaches any
// of the arcade lookups, nor the module's own on/off switch.
fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> { fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> {
if !flag(&body["arcade"]) || disabled() { if !flag(&body["arcade"]) || disabled() {
return None; return None;
@@ -444,14 +215,6 @@ fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i6
cabinet_account_at(login_token, now) cabinet_account_at(login_token, now)
} }
// The account id when this really is an arcade play, None when the live should
// run the ordinary way.
//
// On top of the account rule above, /live/end has to agree with the /live/start
// it belongs to. start_live recorded the whole start body (live.rs:331), so the
// flag it carried is still there to read: a live that began as an ordinary play
// ends as one however its /live/end body is flagged, and a client cannot turn a
// finished LP-paid play into a free one after the fact.
pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option<i64> { pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option<i64> {
let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?; let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?;
let Some(started) = live::get_started_live(login_token, body) else { let Some(started) = live::get_started_live(login_token, body) else {
@@ -465,48 +228,31 @@ pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option<i64> {
Some(user_id) Some(user_id)
} }
// The cabinet a play is attributed to. A machine's own two accounts belong to
// it outright; a card account belongs to nobody, so it is credited to the
// machine that most recently ran a session for that card.
fn play_machine(user_id: i64) -> Option<String> { fn play_machine(user_id: i64) -> Option<String> {
if let Some(machine) = database::machine_of_account(user_id) { if let Some(machine) = database::machine_of_account(user_id) {
return machine["machine_id"].as_str().map(str::to_string); return machine["machine_id"].as_str().map(str::to_string);
} }
database::last_machine_of_card_account(user_id) database::last_machine_of_cards(&migration::cards_of_account(user_id))
} }
// A live starting on a cabinet is a sighting for it: last_seen is what the TTL
// sweeper measures, and a machine in daily use must never age out under it.
//
// It is also the credit saying it is still going. A song that runs long - or a
// second song of the same credit - pushes the card's window back so the play it
// is part of cannot expire underneath it, up to the ceiling above.
pub fn live_started(login_token: &str, body: &JsonValue) { pub fn live_started(login_token: &str, body: &JsonValue) {
let now = global::timestamp() as i64; let now = global::timestamp() as i64;
let Some(user_id) = arcade_account_at(login_token, body, now) else { return; }; let Some(user_id) = arcade_account_at(login_token, body, now) else { return; };
if let Some(machine_id) = play_machine(user_id) { if let Some(machine_id) = play_machine(user_id) {
database::touch_machine(&machine_id); database::touch_machine(&machine_id);
} }
if let Some((card, opened)) = database::live_card_session(user_id, now) { if let Some((card, opened)) = live_card_session(user_id, now) {
let ttl = session_ttl(); let ttl = session_ttl();
database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS)); database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS));
} }
} }
// Credits already paid for the play, so use_lp is no longer what it costs - it
// is only what every reward scales off (live.rs:781). Pinned here to one normal
// 1x play rather than taken from the request: a cabinet that never spends LP
// must not be able to ask for a 10x payout.
pub fn live_end_body(body: &JsonValue) -> JsonValue { pub fn live_end_body(body: &JsonValue) -> JsonValue {
let mut rv = body.clone(); let mut rv = body.clone();
rv["use_lp"] = multi_live::boost_lp(1).into(); rv["use_lp"] = multi_live::boost_lp(1).into();
rv rv
} }
// The result rank the client's own result screen shows, derived from the live's
// own thresholds - the same _scoreC/_scoreB/_scoreA/_scoreS columns the score
// missions read (live.rs:465). 4 = S, 3 = A, 2 = B, 1 = C; 0 is below C, and is
// also what a custom song gets, having no official thresholds.
fn score_rank(live_id: i64, score: i64) -> i64 { fn score_rank(live_id: i64, score: i64) -> i64 {
let live = &databases::LIVE_LIST[live_id.to_string()]; let live = &databases::LIVE_LIST[live_id.to_string()];
let mut rank = 0; let mut rank = 0;
@@ -520,18 +266,7 @@ fn score_rank(live_id: i64, score: i64) -> i64 {
rank rank
} }
// A cabinet's failed song. The retire wire carries no `arcade` flag - the
// client's CJsonSendParamLiveRetire is master_live_id, level and live_score and
// nothing else (Protocol.cs:7298-7305) - so the proof that this was a cabinet
// play is the start it belongs to: start_live recorded the whole /live/start
// body, and a cabinet's start is flagged. The account rule on top is /live/end's,
// unchanged.
//
// Read before live.rs's live_retire, which sweeps the very record this reads.
pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option<i64> { pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option<i64> {
// The start record is read before the module's on/off switch so an ordinary
// player's retire - whose start was never flagged - costs one lookup and
// nothing else, exactly as it did before the ledger learned about failures.
let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?; let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?;
if disabled() { if disabled() {
return None; return None;
@@ -547,17 +282,6 @@ fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64>
cabinet_account_at(login_token, now) cabinet_account_at(login_token, now)
} }
// One row in the cabinet's ledger, and a sighting for it. Records nothing when
// the account has no cabinet to attribute the play to - a card bound through the
// webui that has never been to a machine still plays, it is just not anyone's
// bookkeeping.
//
// `cleared` is false for a song reported at /live/retire because its life gauge
// emptied. It is recorded whatever its play_time, unlike the global clear-rate
// counter live.rs:30 gates at five seconds: that gate keeps a rage-quit out of a
// public board, while a credit's song is a song of that credit either way. The
// score is the one the retire carried, and the rank is what that score is worth
// against the live's own thresholds - `cleared` is what tells the two apart.
pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) { pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) {
let Some(machine_id) = play_machine(user_id) else { return; }; let Some(machine_id) = play_machine(user_id) else { return; };
let live_id = body["master_live_id"].as_i64().unwrap_or(0); let live_id = body["master_live_id"].as_i64().unwrap_or(0);
@@ -567,20 +291,14 @@ pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) {
database::touch_machine(&machine_id); database::touch_machine(&machine_id);
} }
// -- maintenance ------------------------------------------------------------ // maintenance
// Machines unseen for --arcade-machine-ttl days, deleted with the two accounts
// each of them owns. Run from the --purge sweep at boot.
//
// A card-bound player account is never touched here, and neither is a machine
// or guest account somebody has bound a card to: cards outlive cabinets by
// design, and the account behind one is a player's.
pub fn purge_machines() -> usize { pub fn purge_machines() -> usize {
if disabled() { if disabled() {
return 0; return 0;
} }
let ttl = machine_ttl_days(); let ttl = machine_ttl_days();
// 0 is "never age a cabinet out", not "age every cabinet out this second" // 0 = never
if ttl == 0 { if ttl == 0 {
return 0; return 0;
} }
@@ -598,7 +316,7 @@ fn purge_machines_before(cutoff: i64) -> usize {
); );
for key in ["machine_user_id", "guest_user_id"] { for key in ["machine_user_id", "guest_user_id"] {
let user_id = machine[key].as_i64().unwrap_or(0); let user_id = machine[key].as_i64().unwrap_or(0);
if user_id != 0 && !database::account_has_card(user_id) { if user_id != 0 && !migration::account_has_card(user_id) {
userdata::delete_account(user_id); userdata::delete_account(user_id);
} }
} }
@@ -607,10 +325,8 @@ fn purge_machines_before(cutoff: i64) -> usize {
dead.len() dead.len()
} }
// -- webui ------------------------------------------------------------------ // webui
// The operator's machine list: name, id, last seen and how many lives the
// cabinet has recorded.
pub fn webui_machines() -> JsonValue { pub fn webui_machines() -> JsonValue {
if disabled() { if disabled() {
return jzon::array![]; return jzon::array![];
@@ -618,8 +334,6 @@ pub fn webui_machines() -> JsonValue {
database::list_machines() database::list_machines()
} }
// Retiring a cabinet by hand: the same deletion the TTL sweeper performs, with
// the same rule about accounts a card has claimed.
pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> { pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> {
if disabled() { if disabled() {
return Err(String::from("Arcade mode is disabled on this server")); return Err(String::from("Arcade mode is disabled on this server"));
@@ -629,7 +343,7 @@ pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> {
}; };
for key in ["machine_user_id", "guest_user_id"] { for key in ["machine_user_id", "guest_user_id"] {
let user_id = machine[key].as_i64().unwrap_or(0); let user_id = machine[key].as_i64().unwrap_or(0);
if user_id != 0 && !database::account_has_card(user_id) { if user_id != 0 && !migration::account_has_card(user_id) {
userdata::delete_account(user_id); userdata::delete_account(user_id);
} }
} }
@@ -638,6 +352,9 @@ pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
// Rest of file is tests
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -663,14 +380,6 @@ mod tests {
assert!(card_id(&object!{ card_id: "" }).is_none()); assert!(card_id(&object!{ card_id: "" }).is_none());
assert!(card_id(&object!{ card_id: "0123-4567" }).is_none()); assert!(card_id(&object!{ card_id: "0123-4567" }).is_none());
assert!(card_id(&object!{ card_id: "'; DROP TABLE cards--" }).is_none()); assert!(card_id(&object!{ card_id: "'; DROP TABLE cards--" }).is_none());
assert!(card_id(&object!{ card_id: "x".repeat(MAX_CARD_ID_LEN + 1) }).is_none());
}
// The account a fresh card gets is named after the card
#[test]
fn a_new_card_is_named_after_its_last_four_digits() {
assert_eq!(card_account_name("0123456789012345"), "2345");
assert_eq!(card_account_name("12"), "12");
} }
// The rank stored in the ledger is the one the result screen shows, read off // The rank stored in the ledger is the one the result screen shows, read off
@@ -689,53 +398,33 @@ mod tests {
assert_eq!(score_rank(10_000, 9_999_999), 0); assert_eq!(score_rank(10_000, 9_999_999), 0);
} }
// The card-rebind cleanup only ever takes an account that is provably a // A card resolves to the account it names, and to nothing at all once that
// throwaway: a real player's account survives losing its card // account is gone - and the dead mapping does not survive the answer
#[test] #[test]
fn only_an_untouched_throwaway_follows_its_card() { fn a_card_resolves_to_its_account_and_a_dead_mapping_is_dropped() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
// Never played, no card, no password, not a cabinet: a throwaway // Nobody linked it: unlinked, and nothing was created for it
let (orphan, _) = userdata::starter::create("2345").unwrap(); assert!(resolve_card("7020392000000001").is_none());
assert!(orphan_of_a_rebound_card(orphan)); assert!(migration::card_user("7020392000000001").is_none());
// Somebody played on it // Linked: the account and its token
let (played, played_token) = userdata::starter::create("2346").unwrap(); let (player, token) = userdata::starter::create("Linked").unwrap();
let mut user = userdata::get_acc_from_uid(played); migration::set_card("7020392000000002", player);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap(); assert_eq!(resolve_card("7020392000000002"), Some((player, token)));
userdata::save_acc(&played_token, user);
assert!(!orphan_of_a_rebound_card(played));
// Somebody can take it over from a phone // The account was deleted: unlinked from now on, mapping gone
let (phone, _) = userdata::starter::create("2347").unwrap(); userdata::delete_account(player);
userdata::user::migration::save_acc_transfer(phone, "hunter2"); assert!(resolve_card("7020392000000002").is_none());
assert!(!orphan_of_a_rebound_card(phone)); assert!(migration::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived");
// Another card still points at it
let (shared, _) = userdata::starter::create("2348").unwrap();
crate::database::arcade::set_card("9999888877776666", shared);
assert!(!orphan_of_a_rebound_card(shared));
// It is a cabinet's own identity
let (machine_account, _) = userdata::starter::create("Cabinet").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
let machine_id = crate::database::arcade::generate_machine_id();
crate::database::arcade::insert_machine(&machine_id, "Cabinet", machine_account, guest_account);
assert!(!orphan_of_a_rebound_card(machine_account));
assert!(!orphan_of_a_rebound_card(guest_account));
// An account that no longer exists is not deleted twice
userdata::delete_account(orphan);
assert!(!orphan_of_a_rebound_card(orphan));
crate::database::arcade::delete_machine(&machine_id);
} }
// bind_card_to is the rule under both entrances - the cabinet's transfer // bind_card_to is the cabinet's entrance to the shared link rule
// proof and the webui's session - so whatever named the account, a cabinet's // (migration::link_card): a cabinet's own identities are refused and a card
// own identities are refused, and a provable throwaway goes with its card // that already names an account is re-pointed, never refused
#[test] #[test]
fn bind_card_to_refuses_cabinet_identities_and_takes_the_throwaway_with_the_card() { fn bind_card_to_refuses_cabinet_identities_and_repoints_a_linked_card() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db; use crate::database::arcade as db;
@@ -747,33 +436,26 @@ mod tests {
// The id is validated, not repaired // The id is validated, not repaired
assert!(bind_card_to("0123-4567", player).is_err()); assert!(bind_card_to("0123-4567", player).is_err());
assert!(db::card_user("0123-4567").is_none()); assert!(migration::card_user("0123-4567").is_none());
// Neither cabinet identity may sit behind a card // Neither cabinet identity may sit behind a card
let card = "4242424242424242"; let card = "4242424242424242";
assert!(bind_card_to(card, machine_account).is_err()); assert!(bind_card_to(card, machine_account).is_err());
assert!(bind_card_to(card, guest_account).is_err()); assert!(bind_card_to(card, guest_account).is_err());
assert!(db::card_user(card).is_none()); assert!(migration::card_user(card).is_none());
// A throwaway the card was carrying leaves with it... // A card another player linked is re-pointed, and that player's account
let (orphan, _) = userdata::starter::create("2424").unwrap(); // is untouched - the card was the only thing that changed hands
db::set_card(card, orphan); let (previous, _) = userdata::starter::create("Previous").unwrap();
migration::set_card(card, previous);
assert_eq!(bind_card_to(card, player), Ok(player)); assert_eq!(bind_card_to(card, player), Ok(player));
assert_eq!(db::card_user(card), Some(player)); assert_eq!(migration::card_user(card), Some(player));
assert!(userdata::get_acc_from_uid(orphan)["error"].as_bool().unwrap_or(false), "the throwaway survived"); assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account");
// ...but an account somebody played on stays
let (played, played_token) = userdata::starter::create("2425").unwrap();
let mut user = userdata::get_acc_from_uid(played);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
userdata::save_acc(&played_token, user);
let other = "2525252525252525";
db::set_card(other, played);
assert_eq!(bind_card_to(other, player), Ok(player));
assert_eq!(db::card_user(other), Some(player));
assert!(!userdata::get_acc_from_uid(played)["error"].as_bool().unwrap_or(false), "a played-on account followed its card");
db::delete_machine(&machine_id); db::delete_machine(&machine_id);
for id in [player, previous, machine_account, guest_account] {
userdata::delete_account(id);
}
} }
// A cabinet's song that ended with an empty life gauge is reported at // A cabinet's song that ended with an empty life gauge is reported at
@@ -870,11 +552,11 @@ mod tests {
let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap(); let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
let claimed_id = db::generate_machine_id(); let claimed_id = db::generate_machine_id();
db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest); db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest);
db::set_card("4444333322221111", claimed_machine); migration::set_card("4444333322221111", claimed_machine);
// A player account with a card, belonging to no cabinet at all // A player account with a card, belonging to no cabinet at all
let (player, player_token) = userdata::starter::create("Player").unwrap(); let (player, player_token) = userdata::starter::create("Player").unwrap();
db::set_card("8888777766665555", player); migration::set_card("8888777766665555", player);
// Everything registered above is "seen now"; only the two we push back // Everything registered above is "seen now"; only the two we push back
// are older than the cutoff // are older than the cutoff
@@ -897,91 +579,11 @@ mod tests {
// The claimed cabinet is retired, but the account behind its card is not // The claimed cabinet is retired, but the account behind its card is not
assert!(db::get_machine(&claimed_id).is_none()); assert!(db::get_machine(&claimed_id).is_none());
assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine); assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine);
assert_eq!(db::card_user("4444333322221111"), Some(claimed_machine)); assert_eq!(migration::card_user("4444333322221111"), Some(claimed_machine));
// A card-bound player account is never a candidate in the first place // A card-bound player account is never a candidate in the first place
assert_eq!(userdata::uid_from_login_token(&player_token), player); assert_eq!(userdata::uid_from_login_token(&player_token), player);
db::delete_machine(&live_id); db::delete_machine(&live_id);
} }
// A card the server has never seen gets an account named after its own last
// four digits, and the name the player then types at the cabinet lands on
// that account - and on nothing else. The rename is a second /session for
// the same card, so every other account has to be immune to it.
#[test]
fn a_new_cards_account_takes_the_name_the_player_types() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
const TTL: i64 = 30 * 60;
let now = global::timestamp() as i64;
let card = "6060606060602345";
let machine_id = db::generate_machine_id();
// /api/arcade/session, first call: an unknown card, so the account is
// created under the card's last four digits and the credit's window opens
let name = card_account_name(card);
assert_eq!(name, "2345");
let (user_id, token) = userdata::starter::create(&name).unwrap();
db::set_card(card, user_id);
db::open_card_session(card, &machine_id, now + TTL);
// ...and the same call again, carrying the name the player typed
assert!(issued_by_this_credit(card, user_id, now, TTL));
rename_account(user_id, &token, "Ethan");
assert_eq!(userdata::get_name_and_rank(user_id)["user_name"].as_str(), Some("Ethan"));
// Once named it is somebody's account: a second name entry on the same
// card cannot touch it
assert!(!issued_by_this_credit(card, user_id, now, TTL), "a named account was still renameable");
// Neither can one whose credit has ended - the window is the proof
let later_card = "6060606060601111";
let (later, _) = userdata::starter::create(&card_account_name(later_card)).unwrap();
db::set_card(later_card, later);
db::open_card_session(later_card, &machine_id, now + TTL);
db::backdate_card_session_for_test(later_card, now - TTL - 1, now + TTL);
assert!(!issued_by_this_credit(later_card, later, now, TTL), "an old credit could still rename");
db::backdate_card_session_for_test(later_card, now, now - 1);
assert!(!issued_by_this_credit(later_card, later, now, TTL), "a closed window could still rename");
// Nor a card presented at one cabinet renaming through another card's row
db::open_card_session(later_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(card, later, now, TTL), "a different card's id could rename");
// A phone account behind a card is never renameable, however fresh its
// window is: it has a transfer password, and its name is its own
let phone_card = "6060606060602222";
let (phone, _) = userdata::starter::create(&card_account_name(phone_card)).unwrap();
userdata::user::migration::save_acc_transfer(phone, "hunter2");
db::set_card(phone_card, phone);
db::open_card_session(phone_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(phone_card, phone, now, TTL), "a phone account was renameable");
// And neither is an account that has already played something
let played_card = "6060606060603333";
let (played, played_token) = userdata::starter::create(&card_account_name(played_card)).unwrap();
db::set_card(played_card, played);
db::open_card_session(played_card, &machine_id, now + TTL);
assert!(issued_by_this_credit(played_card, played, now, TTL));
let mut user = userdata::get_acc_from_uid(played);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
userdata::save_acc(&played_token, user);
assert!(!issued_by_this_credit(played_card, played, now, TTL), "a played account was renameable");
// Finally: a cabinet's own identity, which a card may not name at all
let (machine_account, _) = userdata::starter::create("Cabinet Name").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
db::insert_machine(&machine_id, "Cabinet Name", machine_account, guest_account);
let cabinet_card = "6060606060604444";
db::set_card(cabinet_card, guest_account);
db::open_card_session(cabinet_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(cabinet_card, guest_account, now, TTL), "a cabinet identity was renameable");
db::delete_machine(&machine_id);
for id in [user_id, later, phone, played, machine_account, guest_account] {
userdata::delete_account(id);
}
}
} }
+315
View File
@@ -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);
}
}
+314
View File
@@ -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
]
}
]
+11
View File
@@ -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);
+68
View File
@@ -60,6 +60,7 @@ pub fn add_chat_from_chapter_id(chapter_id: i64, chats: &mut JsonValue) -> bool
} }
async fn home(Login(key): Login) -> impl Responder { 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
+208 -18
View File
@@ -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;
@@ -386,6 +387,79 @@ lazy_static! {
} }
rv rv
}; };
// NERF_CUSTOM_CARDS: per-rarity (hp, smile, cool, pure) ceilings set ~200
// below the median official card of that rarity (official medians: R ~2160,
// SR ~4010, UR ~4366 -> caps 2000 / 3800 / 4200), so a nerfed custom card
// stays under a typical official one. Skill magnitudes nerf to 4/5 of the
// official ranges
static ref NERF_STAT_CAPS: HashMap<i64, (i64, i64, i64, i64)> = {
let mut rv: HashMap<i64, (i64, i64, i64, i64)> = HashMap::new();
for (&rarity, (hp, _, _, _)) in &*STAT_CAPS {
let cap = match rarity {
1 => 2000,
2 => 3800,
_ => 4200,
};
rv.insert(rarity, (*hp, cap, cap, cap));
}
rv
};
}
// The nerf band. Read from the runtime flag rather than get_args(): the
// host-config path (set_nerf_custom_cards, mirrored by overlay_args) is the
// one the lib mode and the tests reach, and get_args() chokes on the test
// harness's own CLI arguments (the same reason set_time is avoided in tests)
pub fn nerf_active() -> bool {
crate::runtime::get_nerf_custom_cards()
}
// Skill magnitudes nerf to 4/5 of the official range
fn nerf_scale(value: i64) -> i64 {
if value > 0 { (value * 4 / 5).max(1) } else { value * 4 / 5 }
}
// A stored skill array element above the cap moves down to the cap; below it
// is untouched
fn nerf_clamp_element(value: &mut JsonValue, cap: i64) {
if cap > 0 && value.as_i64().unwrap_or(0) > cap {
*value = cap.into();
}
}
// NERF_CUSTOM_CARDS clamps a card's stored stats and skill magnitudes to the
// nerf band WITHOUT touching the db row - the db keeps the owner's values,
// every response carries the clamped ones. No-op below the caps, so an
// ordinary card passes through unchanged
fn nerf_clamp_card(card: &mut JsonValue) {
let rarity = card["rarity"].as_i64().unwrap_or(0);
if let Some((hp, smile, cool, pure)) = NERF_STAT_CAPS.get(&rarity) {
for (key, cap) in [("hp", *hp), ("smile", *smile), ("cool", *cool), ("pure", *pure)] {
nerf_clamp_element(&mut card[key], cap);
}
}
if card["skill"].is_null() {
return;
}
let effect_type = card["skill"]["effect_type"].as_i64().unwrap_or(0);
let trigger = card["skill"]["trigger"].as_i64().unwrap_or(0);
let (_, value_max, _) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(0, 0, "official"));
let (_, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(0, 0));
let ((_, prob_max), (_, ms_max)) = *SKILL_SCALAR_RANGES;
for (key, cap) in [
("trigger_value", nerf_scale(trigger_max)),
("probability", nerf_scale(prob_max)),
("effective_milli_secs", nerf_scale(ms_max)),
("effective_values", nerf_scale(value_max))
] {
if !card["skill"][key].is_array() {
continue;
}
for value in card["skill"][key].members_mut() {
nerf_clamp_element(value, cap);
}
}
} }
// Game endpoints (/api scope, standard envelope) // Game endpoints (/api scope, standard envelope)
@@ -525,10 +599,15 @@ pub fn strip_unsupported(user: &mut JsonValue) {
// The concrete upload bounds, served to the webui so the form can enforce // The concrete upload bounds, served to the webui so the form can enforce
// them client-side (sliders/radios) and reject out-of-range values instantly // them client-side (sliders/radios) and reject out-of-range values instantly
pub fn upload_limits() -> JsonValue { pub fn upload_limits() -> JsonValue {
let nerfed = nerf_active();
let mut stat_caps = object!{}; let mut stat_caps = object!{};
let mut skill_levels = object!{}; let mut skill_levels = object!{};
for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX { for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX {
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); let caps = if nerfed {
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
} else {
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
};
stat_caps[rarity.to_string()] = object!{ stat_caps[rarity.to_string()] = object!{
"hp": caps.0, "hp": caps.0,
"smile": caps.1, "smile": caps.1,
@@ -553,11 +632,12 @@ pub fn upload_limits() -> JsonValue {
} }
// The skill magnitude envelopes, so the form's number inputs carry real // The skill magnitude envelopes, so the form's number inputs carry real
// min/max and reject out-of-range (and e-notation) before submitting // min/max and reject out-of-range (and e-notation) before submitting
let scale = |v: i64| if nerfed { nerf_scale(v) } else { v };
let mut effect_value_ranges = object!{}; let mut effect_value_ranges = object!{};
for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() { for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() {
effect_value_ranges[effect.to_string()] = object!{ effect_value_ranges[effect.to_string()] = object!{
"min": *min, "min": *min,
"max": *max, "max": scale(*max),
"source": *source "source": *source
}; };
} }
@@ -565,10 +645,12 @@ pub fn upload_limits() -> JsonValue {
for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() { for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() {
trigger_value_ranges[trigger.to_string()] = object!{ trigger_value_ranges[trigger.to_string()] = object!{
"min": *min, "min": *min,
"max": *max "max": scale(*max)
}; };
} }
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES; let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
let prob_max = scale(prob_max);
let ms_max = scale(ms_max);
// The voiceline moments, so the form renders exactly the kinds this build // The voiceline moments, so the form renders exactly the kinds this build
// accepts instead of carrying its own copy of the list // accepts instead of carrying its own copy of the list
let mut voice_kinds = array![]; let mut voice_kinds = array![];
@@ -619,17 +701,30 @@ 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);
} }
let user = userdata::get_acc(&key); let user = userdata::get_acc(&key);
let uid = user["user"]["id"].as_i64().unwrap(); let uid = user["user"]["id"].as_i64().unwrap();
let cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user)); let mut cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
let characters = database::get_characters_for_cards(uid, &cards); if nerf_active() {
for card in cards.members_mut() {
nerf_clamp_card(card);
}
}
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
})) }))
@@ -1121,13 +1216,21 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
// Note the official HP scale before assuming a bug report: hp is a tiny // Note the official HP scale before assuming a bug report: hp is a tiny
// per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4) // per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4)
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); // NERF_CUSTOM_CARDS drops the ceiling from the official rarity max to the
// nerf band (~200 below the official median) for this upload
let caps = if nerf_active() {
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
} else {
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
};
let nerfed = nerf_active();
let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)]; let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)];
let mut values = Vec::new(); let mut values = Vec::new();
for (key, cap) in stats { for (key, cap) in stats {
let value = number_of(fields, key, stored, key); let value = number_of(fields, key, stored, key);
if !(1..=cap).contains(&value) { if !(1..=cap).contains(&value) {
return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name)); let range = if nerfed { "nerfed" } else { "official" };
return Err(format!("{} must be between 1 and {} for a {} card (the {} {} range)", key, cap, rarity_name, range, rarity_name));
} }
values.push(value); values.push(value);
} }
@@ -1177,17 +1280,22 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
// effective_milli_secs may be 1 long). MAGNITUDES are clamped to the // effective_milli_secs may be 1 long). MAGNITUDES are clamped to the
// ranges the shipped skill rows actually use - a 40-second buff and a // ranges the shipped skill rows actually use - a 40-second buff and a
// 1e8 score-up both made it through before these bounds existed // 1e8 score-up both made it through before these bounds existed
// NERF_CUSTOM_CARDS drops the magnitude ceilings to 4/5 of the official
// range; the floor stays the official one so a nerfed card can never be
// weaker than the weakest official row of that effect
let nerfed = nerf_active();
let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1)); let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1));
let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official")); let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official"));
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES; let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
let range_word = if nerfed { "nerfed" } else { "official" };
let bounds = [ let bounds = [
("skill_trigger_value", "trigger_value", trigger_min, trigger_max, ("skill_trigger_value", "trigger_value", trigger_min, if nerfed { nerf_scale(trigger_max) } else { trigger_max },
format!("for trigger {} (the official range)", trigger)), format!("for trigger {} (the {} range)", trigger, range_word)),
("skill_probability", "probability", prob_min, prob_max, ("skill_probability", "probability", prob_min, if nerfed { nerf_scale(prob_max) } else { prob_max },
format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)), format!("({}%-{}%, the {} range)", prob_min / 1000, (if nerfed { nerf_scale(prob_max) } else { prob_max }) / 1000, range_word)),
("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max, ("skill_effective_milli_secs", "effective_milli_secs", ms_min, if nerfed { nerf_scale(ms_max) } else { ms_max },
String::from("milliseconds (the official range)")), format!("milliseconds (the {} range)", range_word)),
("skill_effective_values", "effective_values", value_min, value_max, ("skill_effective_values", "effective_values", value_min, if nerfed { nerf_scale(value_max) } else { value_max },
format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source)) format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source))
]; ];
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new(); let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
@@ -1253,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"),
@@ -1299,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"),
@@ -1389,7 +1504,13 @@ pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(),
if !can_manage(uid, owner) { if !can_manage(uid, owner) {
return Err(String::from("You do not have permission to edit this card")); return Err(String::from("You do not have permission to edit this card"));
} }
let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?; let mut stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
// An edit's "unchanged" fallbacks read from the stored blob, so the
// stored reference itself is brought into the nerf band first - an
// over-cap value the form leaves untouched stays over cap in the dry-run
if nerf_active() {
nerf_clamp_card(&mut stored);
}
let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0); let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0);
// Field validation first (cheap), art processing second - fail fast // Field validation first (cheap), art processing second - fail fast
@@ -1726,9 +1847,15 @@ async fn mine(req: HttpRequest) -> HttpResponse {
let Some(uid) = get_session_uid(&req) else { let Some(uid) = get_session_uid(&req) else {
return webui::error("Not logged in"); return webui::error("Not logged in");
}; };
let mut cards = database::get_cards_by_owner(uid);
if nerf_active() {
for card in cards.members_mut() {
nerf_clamp_card(card);
}
}
send_json(object!{ send_json(object!{
result: "OK", result: "OK",
cards: database::get_cards_by_owner(uid), cards: cards,
characters: database::get_characters_by_owner(uid) characters: database::get_characters_by_owner(uid)
}) })
} }
@@ -1744,6 +1871,9 @@ async fn browse(_req: HttpRequest) -> HttpResponse {
for card in cards.members_mut() { for card in cards.members_mut() {
card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone(); card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone();
card.remove("owner_id"); card.remove("owner_id");
if nerf_active() {
nerf_clamp_card(card);
}
} }
let characters = database::get_characters_for_cards(0, &cards); let characters = database::get_characters_for_cards(0, &cards);
send_json(object!{ send_json(object!{
@@ -2197,6 +2327,8 @@ pub mod tests {
assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range"); assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range");
let (_, value_max, _) = SKILL_VALUE_RANGES[&4]; let (_, value_max, _) = SKILL_VALUE_RANGES[&4];
assert!(value_max < 100_000_000, "1e8 score-ups must be out of range"); assert!(value_max < 100_000_000, "1e8 score-ups must be out of range");
let (repeat_min, repeat_max, _) = SKILL_VALUE_RANGES[&11];
assert_eq!((repeat_min, nerf_scale(repeat_max)), (1, 1));
} }
// A full create: derived ids, pinned columns, art md5s and the catalog // A full create: derived ids, pinned columns, art md5s and the catalog
@@ -2486,6 +2618,64 @@ pub mod tests {
wipe(4004); wipe(4004);
} }
// NERF_CUSTOM_CARDS: an over-cap upload is rejected, an over-cap value the
// owner already stored is clamped in every served view (db row untouched),
// and the skill ceiling drops to 4/5 of the official range
#[test]
fn nerf_band_caps_new_and_existing_cards() {
let _lock = crate::runtime::lock_test_data_path();
wipe(5005);
crate::runtime::set_nerf_custom_cards(false);
// Un-nerfed: the official rarity ceiling (4110 for R) is reachable
let fields = base_fields();
let id = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &fields).unwrap());
let mut over = base_fields();
field(&mut over, "smile", "4110");
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &over).unwrap());
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
// Nerfed: a new 4110 upload is refused at the nerf band (2000 for R)...
crate::runtime::set_nerf_custom_cards(true);
let mut high = base_fields();
field(&mut high, "smile", "4110");
let err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &high)).unwrap_err();
assert!(err.contains("smile must be between 1 and 2000 for a R card") && err.contains("nerfed"), "{}", err);
// Boundary: 2000 accepted, 2001 refused. The fixture's trigger values
// (25/24/24) sit in the official 13-30 but above the nerfed 13-24, so
// a nerfed-boundary upload carries a trigger value legal in both bands
let mut at_cap = base_fields();
field(&mut at_cap, "smile", "2000");
field(&mut at_cap, "skill_trigger_value", "20/20/20");
let at_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &at_cap));
assert!(at_cap_err.is_ok(), "2000 should be accepted: {:?}", at_cap_err.err());
let mut over_cap = base_fields();
field(&mut over_cap, "smile", "2001");
let over_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &over_cap)).unwrap_err();
assert!(over_cap_err.contains("must be between 1 and 2000"), "{}", over_cap_err);
// ...and the ALREADY-stored 4110 is clamped to the cap in the served view
let mut view = database::get_card(id).unwrap();
nerf_clamp_card(&mut view);
assert_eq!(view["smile"].as_i64(), Some(2000));
// while the db keeps the owner's stored value
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
// Skill magnitudes: the ceiling is 4/5 of the official max (439 -> 351)
crate::runtime::set_nerf_custom_cards(false);
let mut strong = base_fields();
field(&mut strong, "skill_effective_values", "439/439/439");
field(&mut strong, "skill_trigger_value", "20/20/20");
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong).unwrap());
crate::runtime::set_nerf_custom_cards(true);
let skill_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong)).unwrap_err();
// 439 * 4/5 = 351: the official 91-439 range is capped at 351
assert!(skill_err.contains("91-351"), "{}", skill_err);
crate::runtime::set_nerf_custom_cards(false);
wipe(5005);
}
#[test] #[test]
fn permission_gates() { fn permission_gates() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
+181
View File
@@ -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);
}
}
+49 -177
View File
@@ -2,9 +2,6 @@
// in-process symphonia + vorbis machinery // in-process symphonia + vorbis machinery
pub mod audio; pub mod audio;
mod chart; mod chart;
// One-time startup regroup of charts stored before the spawn-group pairing rule; called from
// run_server, no-op when the feature is disabled or every chart is already correctly grouped
pub mod migrate;
mod package; mod package;
use jzon::{array, object, JsonValue}; use jzon::{array, object, JsonValue};
@@ -18,6 +15,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 +37,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 +139,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 +166,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 +667,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 +765,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 +781,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 +896,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 +1050,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 +1075,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,
@@ -1439,109 +1467,11 @@ mod tests {
fn field(fields: &mut HashMap<String, Vec<u8>>, key: &str, value: &str) { fn field(fields: &mut HashMap<String, Vec<u8>>, key: &str, value: &str) {
fields.insert(String::from(key), value.as_bytes().to_vec()); fields.insert(String::from(key), value.as_bytes().to_vec());
} }
// The live PLAY cue must never be a loop cue: the client reports a looping playback as
// A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a
// full 9-lane wall (the shape of the field-reported chart that exposed the old encoding)
// 4 parallel holds then a 9-wide wall. Timed to fit inside the 2s test track: uploads are
// rejected when a chart outlives its audio (validate_chart_fits_audio)
fn wall_chart() -> Vec<u8> {
let mut beatmap = jzon::array![];
for position in [2, 4, 6, 8] {
beatmap.push(jzon::object!{
"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1,
"effect": 3, "effect_value": 0.5, "position": position
}).unwrap();
}
for position in 1..=9 {
beatmap.push(jzon::object!{
"timing_sec": 1.5, "notes_attribute": 1, "notes_level": 1,
"effect": 1, "effect_value": 0.0, "position": position
}).unwrap();
}
jzon::stringify(beatmap).into_bytes()
}
// The startup migration: a chart stored with the PRE-pairing encoding (whole equal-time
// clusters sharing one num) is regrouped in place, its catalog md5/size follow the new
// bytes, the revision bumps exactly once, correctly-encoded songs stay byte-identical,
// and a second run is a complete no-op
#[test]
fn startup_migration_regroups_pre_fix_charts() {
let _lock = crate::runtime::lock_test_data_path();
let mut fields = HashMap::new();
field(&mut fields, "name", "Migration Target");
field(&mut fields, "artist", "Wall Artist");
field(&mut fields, "attribute", "1");
field(&mut fields, "level_number_4", "15");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(550.0));
fields.insert(String::from("chart_4"), wall_chart());
let target = create_song(3333, &fields).unwrap();
let mut fields = HashMap::new();
field(&mut fields, "name", "Migration Control");
field(&mut fields, "artist", "Control Artist");
field(&mut fields, "attribute", "2");
field(&mut fields, "level_number_1", "5");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(770.0));
fields.insert(String::from("chart_1"), test_chart());
let control = create_song(4444, &fields).unwrap();
// The upload stored the CURRENT encoding; capture it, then doctor the store back to
// the pre-pairing form exactly as an old server would have written it: squashed
// chart bytes on disk and the catalog md5/size matching those bytes
let path = song_path(target, "chart_4.json");
let fixed_bytes = fs::read(&path).unwrap();
let mut squashed = jzon::parse(&String::from_utf8_lossy(&fixed_bytes)).unwrap();
chart::squash_to_pre_fix(&mut squashed);
let squashed_bytes = jzon::stringify(squashed).into_bytes();
assert_ne!(squashed_bytes, fixed_bytes);
fs::write(&path, &squashed_bytes).unwrap();
let mut song = database::get_song(target).unwrap();
let (md5, size) = asset_meta(&squashed_bytes);
for entry in song["levels"].members_mut() {
if entry["level"] == 4 {
entry["md5"] = md5.clone().into();
entry["size"] = size.into();
}
}
database::update_song(target, &song);
let control_bytes = fs::read(song_path(control, "chart_1.json")).unwrap();
let control_song = database::get_song(control).unwrap();
let revision = database::get_revision();
migrate::run();
// The target chart is byte-identical to what the current transcoder stores, and the
// catalog follows the new bytes
let migrated = fs::read(&path).unwrap();
assert_eq!(migrated, fixed_bytes);
let song = database::get_song(target).unwrap();
let level = song["levels"].members().find(|l| l["level"] == 4).unwrap();
let (md5, size) = asset_meta(&fixed_bytes);
assert_eq!(level["md5"].to_string(), md5);
assert_eq!(level["size"].as_usize().unwrap(), size);
// full_combo never depended on grouping and must not move
assert_eq!(level["full_combo"], 9 + 4);
// Exactly one revision bump, and the control song is untouched
assert_eq!(database::get_revision(), revision + 1);
assert_eq!(fs::read(song_path(control, "chart_1.json")).unwrap(), control_bytes);
assert_eq!(jzon::stringify(database::get_song(control).unwrap()), jzon::stringify(control_song));
// Idempotent: a second boot changes nothing and bumps nothing
migrate::run();
assert_eq!(fs::read(&path).unwrap(), fixed_bytes);
assert_eq!(database::get_revision(), revision + 1);
}
// The live PLAY cue must never be a loop cue: the client reports a looping playback as // The live PLAY cue must never be a loop cue: the client reports a looping playback as
// forever-playing, and the live's end trigger waits on playback-end, so a looping play cue // forever-playing, and the live's end trigger waits on playback-end, so a looping play cue
// means the live never ends. New uploads emit is_loop:false, the preview cue keeps looping, // means the live never ends. New uploads emit is_loop:false; the preview cue keeps looping.
// and the startup migration un-loops catalogs written before the distinction existed.
#[test] #[test]
fn play_cue_never_loops() { fn play_cue_never_loops() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();
@@ -1563,28 +1493,6 @@ mod tests {
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0); assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
assert_eq!(song["sound"]["select"]["is_loop"], true); assert_eq!(song["sound"]["select"]["is_loop"], true);
assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0); assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0);
// Doctor the catalog back to the pre-fix shape an old server would have written,
// then boot: the migration un-loops the play cue and bumps the revision once
let mut old = song.clone();
old["sound"]["play"]["is_loop"] = true.into();
old["sound"]["play"]["loop_end_sec"] = old["sound"]["play"]["duration_sec"].clone();
database::update_song(music_id, &old);
let revision = database::get_revision();
migrate::run();
let song = database::get_song(music_id).unwrap();
assert_eq!(song["sound"]["play"]["is_loop"], false);
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
assert_eq!(song["sound"]["select"]["is_loop"], true);
// The ogg bytes never moved, so the audio md5 must not change (no re-download)
assert_eq!(song["sound"]["play"]["md5"], old["sound"]["play"]["md5"]);
assert_eq!(database::get_revision(), revision + 1);
// Idempotent
migrate::run();
assert_eq!(database::get_revision(), revision + 1);
} }
// Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an // Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an
@@ -1728,55 +1636,6 @@ mod tests {
update_song(music_id, &fields).unwrap(); update_song(music_id, &fields).unwrap();
} }
// The startup migration also corrects the two fabricated masterdata values in stored
// catalogs: score-rank thresholds (official constants, not per-song) and combo missions
// (20/40/60/80% of the hardest full combo, not 25/50/75/100%)
#[test]
fn startup_migration_fixes_fabricated_scores_and_missions() {
let _lock = crate::runtime::lock_test_data_path();
let mut fields = HashMap::new();
field(&mut fields, "name", "Old Values");
field(&mut fields, "artist", "Old Artist");
field(&mut fields, "attribute", "3");
field(&mut fields, "level_number_1", "5");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(1100.0));
fields.insert(String::from("chart_1"), wall_chart());
let music_id = create_song(7777, &fields).unwrap();
let mut song = database::get_song(music_id).unwrap();
let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap();
// Doctor the catalog back to the pre-fix formulas
let base = hardest as f64 * 200.0 * (1.0 + 5.0 / 10.0);
song["score"] = object!{
"c": (base * 0.5) as u32, "b": (base * 0.75) as u32, "a": base as u32, "s": (base * 1.3) as u32
};
song["multi_score"] = object!{
"c": (base * 0.6) as u32, "b": (base * 0.9) as u32, "a": (base * 1.2) as u32, "s": (base * 1.56) as u32
};
song["mission_combo"] = jzon::array![hardest / 4, hardest / 2, hardest * 3 / 4, hardest];
database::update_song(music_id, &song);
let revision = database::get_revision();
migrate::run();
let song = database::get_song(music_id).unwrap();
let (score, multi_score) = default_scores();
assert_eq!(song["score"], score);
assert_eq!(song["multi_score"], multi_score);
assert_eq!(song["score"]["s"], 350000);
assert_eq!(song["multi_score"]["s"], 1225000);
// 20/40/60/80%, and never the full combo itself
assert_eq!(song["mission_combo"], mission_combo(hardest));
assert_eq!(song["mission_combo"][3].as_i64().unwrap(), (hardest as f64 * 0.8 + 0.5) as i64);
assert!(song["mission_combo"][3].as_i64().unwrap() < hardest);
assert_eq!(database::get_revision(), revision + 1);
// Idempotent
migrate::run();
assert_eq!(database::get_revision(), revision + 1);
}
// Export a song, import the package as another user, and the served song // Export a song, import the package as another user, and the served song
// must be identical apart from the assigned music_id - INCLUDING the audio // must be identical apart from the assigned music_id - INCLUDING the audio
@@ -2127,6 +1986,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.
+140 -193
View File
@@ -9,17 +9,17 @@ use std::collections::HashMap;
// note_bomb_3 5, note_bomb_5 6, note_bomb_9 7, note_slide 11, note_slide_event 12, // note_bomb_3 5, note_bomb_5 6, note_bomb_9 7, note_slide 11, note_slide_event 12,
// note_slide_hold 13, with isHold(e) = e == 3 and isSlide(e) = e >= 11. // note_slide_hold 13, with isHold(e) = e == 3 and isSlide(e) = e >= 11.
// //
// SIF2 side: `type` only distinguishes an ordinary note (1) from a star/bomb note (3). // SIF2 side: type 1 is a tap, 2 is a flick, and 3 is a skill/star note.
// LiveTimeController.ToMarkerType accepts 1..3 and maps anything else to None; type 2 exists in // LiveTimeController.ToMarkerType accepts 1..3; stock charts happen not to
// the enum but appears in ZERO of the 2146 shipped charts, so nothing emits it here. // use type 2, but imported Bandori charts do.
// //
// A SLIDE is structural, not a type. MarkerData derives it from the parent/child chain: // A SLIDE is structural, not a type. MarkerData derives it from the parent/child chain:
// IsSliderMarker chained and the child is on a DIFFERENT line -> a slide segment // IsSliderMarker chained and the child is on a DIFFERENT line -> a slide segment
// IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold // IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold
// IsDistanceMarker has both a parent and a child -> a middle segment // IsDistanceMarker has both a parent and a child -> a middle segment
// So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes; // So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes;
// both are type 1. This is also how the client counts combo (NoteData.CalcMaxCombo: a note whose // both are type 1. Combo follows LiveStatusSystem.PushInputResult, not the editor-only
// child shares its line does not count, its tail does). // NoteData.CalcMaxCombo helper, which undercounts repeated same-lane checkpoints.
// //
// Mapping rules: // Mapping rules:
// - line = position - 1 (both are right-to-left) // - line = position - 1 (both are right-to-left)
@@ -33,16 +33,19 @@ use std::collections::HashMap;
// varies the blast width per effect; SIF2 has one bomb with one damage value, so the // varies the blast width per effect; SIF2 has one bomb with one damage value, so the
// four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped // four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped
// here and the three wider ones arrived as ordinary taps. // here and the three wider ones arrived as ordinary taps.
// - effect 11/12/13 (slide) -> CHAINED across lanes, all type 1. Slides sharing a notes_level // - effect 11/12/13/14 (slide) -> CHAINED across lanes, normally type 1. Slides sharing a notes_level
// form one run: sorted by time and linked parent -> child, so each link crosses lanes and // form one run: sorted by time and linked parent -> child. Cross-lane links
// the client sees a slider. A run ends on effect 13 (slide hold), whose synthesized // are sliders; same-lane links are hold segments. A run ends on effect 13 (slide hold), whose synthesized
// same-line tail then makes it IsSliderLongMarker — the slide settling into a hold the // same-line tail then makes it IsSliderLongMarker — the slide settling into a hold the
// player releases. Verified against a real upload: every notes_level shared by more than // player releases. Verified against a real upload: every notes_level shared by more than
// one note held exactly the slide-effect notes, each a monotonic sweep like // one note held exactly the slide-effect notes, each a monotonic sweep like
// pos 9->8->7->6 with effects 11,11,11,13. // pos 9->8->7->6 with effects 11,11,11,13.
// A lone slide with no chain partner stays a plain tap: a slider needs a cross-lane child. // A lone slide with no chain partner stays a plain note. Same-lane links
// - effect 0 (random) and anything else unknown -> plain type 1. Every effect the game // become hold segments; effect 14 keeps the final point as a flick.
// actually defines is covered above, so this is only a floor for hand-authored charts. // - effects 8/14 are custom-song extensions for a flick and a slide-end flick (type 2).
// Effect 9 is a skill note (type 3); effect 10 is a hold with a flick tail.
// The stock SIF1 vocabulary does not use 8/9/10/14.
// - effect 0 (random) and anything else unknown -> plain type 1.
// - notes_attribute is dropped (SIF2 has no per-note attribute). notes_level is consumed as // - notes_attribute is dropped (SIF2 has no per-note attribute). notes_level is consumed as
// the chain id above and not emitted. // the chain id above and not emitted.
// - ids are sequential from 1 in time order. num is the spawn group: the dummy // - ids are sequential from 1 in time order. num is the spawn group: the dummy
@@ -62,8 +65,8 @@ use std::collections::HashMap;
// ForceGroupId against the other chunk's GroupId and links the lane-closest pair). // ForceGroupId against the other chunk's GroupId and links the lane-closest pair).
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client // - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
// deserializes it verbatim. // deserializes it verbatim.
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same // - max_combo_count excludes a same-lane hold's root and a cross-lane slide settling
// line (the game counts a same-lane hold as one combo for the chain) // into a hold. Further same-lane checkpoints do count (see max_combo_count below).
// Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this. // Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this.
// Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to // Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to
@@ -140,6 +143,42 @@ pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
})) }))
} }
// LiveStatusSystem.PushInputResult skips combo for a non-slider long root or
// IsSliderLongMarker. LiveInputResultControl maps those results to their child.
// All other points count, including repeated checkpoints in a stationary slide.
// Validate the links before repairing stored data; never guess a broken chart's count.
pub fn max_combo_count(chart: &JsonValue) -> Option<i64> {
let notes = &chart["notes"];
if !notes.is_array() || notes.len() < 2 || notes[0]["id"] != 0 {
return None;
}
let mut by_id = HashMap::new();
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let line = note["line"].as_i64()?;
if id <= 0 || !(0..9).contains(&line) || by_id.insert(id, note).is_some() {
return None;
}
}
let mut count = 0;
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let parent_id = note["parent_id"].as_i64()?;
let child_id = note["child_id"].as_i64()?;
let parent = if parent_id == 0 { None } else { Some(*by_id.get(&parent_id)?) };
let child = if child_id == 0 { None } else { Some(*by_id.get(&child_id)?) };
if parent_id == id || child_id == id
|| parent.is_some_and(|p| p["child_id"] != id)
|| child.is_some_and(|c| c["parent_id"] != id) {
return None;
}
let hold_start = child.is_some_and(|c| c["line"] == note["line"])
&& parent.is_none_or(|p| p["line"] != note["line"]);
if !hold_start { count += 1; }
}
Some(count)
}
struct WorkNote { struct WorkNote {
time: f64, time: f64,
line: i64, line: i64,
@@ -153,7 +192,7 @@ struct WorkNote {
// LiveModel.NoteEffect.isHold, widened to note_slide_hold: both carry a duration in // LiveModel.NoteEffect.isHold, widened to note_slide_hold: both carry a duration in
// effect_value (notes.lua isTimeOver adds effect_value for note_hold and note_slide_hold alike). // effect_value (notes.lua isTimeOver adds effect_value for note_hold and note_slide_hold alike).
fn is_hold(effect: i64) -> bool { fn is_hold(effect: i64) -> bool {
effect == 3 || effect == 13 effect == 3 || effect == 10 || effect == 13
} }
// LiveModel.NoteEffect.isSlide // LiveModel.NoteEffect.isSlide
@@ -231,7 +270,10 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
work.push(WorkNote { work.push(WorkNote {
time: timing, time: timing,
line: position - 1, line: position - 1,
kind: if is_bomb(effect) { 3 } else { 1 }, // Effects 8/14 are the custom-song interchange's flick and
// slide-end flick; 9 carries a Bandori skill note.
kind: if effect == 8 || effect == 14 { 2 }
else if effect == 9 || is_bomb(effect) { 3 } else { 1 },
parent: None, parent: None,
child: None child: None
}); });
@@ -249,7 +291,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
work.push(WorkNote { work.push(WorkNote {
time: timing + effect_value, time: timing + effect_value,
line: position - 1, line: position - 1,
kind: 1, kind: if effect == 10 { 2 } else { 1 },
parent: Some(head), parent: Some(head),
child: None child: None
}); });
@@ -257,8 +299,8 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
} }
} }
// Link each slide chain in time order. Consecutive members sit on different lines, which is // Link each slide chain in time order. A cross-lane link becomes a slider
// exactly what makes SIF2 treat the run as a slider rather than a hold. A member that already // and a same-lane link becomes a hold segment. A member that already
// has a child is a slide-hold, i.e. the end of the run, so the chain stops there — its tail // has a child is a slide-hold, i.e. the end of the run, so the chain stops there — its tail
// stays its child and the cross-lane parent link makes it IsSliderLongMarker. // stays its child and the cross-lane parent link makes it IsSliderLongMarker.
for (_, members) in chains.iter() { for (_, members) in chains.iter() {
@@ -273,10 +315,6 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
if work[a].child.is_some() || work[b].parent.is_some() { if work[a].child.is_some() || work[b].parent.is_some() {
break; break;
} }
if work[a].line == work[b].line {
// Same lane would read as a hold, not a slide; skip the link rather than lie
continue;
}
work[a].child = Some(b); work[a].child = Some(b);
work[b].parent = Some(a); work[b].parent = Some(a);
} }
@@ -322,17 +360,9 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
"force_sync_group_id": 0 "force_sync_group_id": 0
}]; }];
let mut max_combo_count = 0;
for index in order.iter() { for index in order.iter() {
let note = &work[*index]; let note = &work[*index];
// NoteData.CalcMaxCombo: a note whose child is on the SAME line (a hold) does not count,
// its tail does. A cross-lane child (a slide segment) counts normally.
match note.child {
Some(child) if work[child].line == note.line => {},
_ => max_combo_count += 1
}
notes.push(object!{ notes.push(object!{
"id": ids[*index], "id": ids[*index],
"num": nums[*index], "num": nums[*index],
@@ -347,120 +377,17 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
}).unwrap(); }).unwrap();
} }
Ok((object!{ let mut chart = object!{
"max_lane": 9, "max_lane": 9,
"sound_name": "", "sound_name": "",
"max_combo_count": max_combo_count, "max_combo_count": 0,
"notes": notes "notes": notes
}, max_combo_count)) };
let combo = max_combo_count(&chart).ok_or("Invalid transcoded note links")?;
chart["max_combo_count"] = combo.into();
Ok((chart, combo))
} }
// Regroups a STORED transcoded chart whose spawn groups predate the pairing rule above: the
// old transcoder gave every note of an equal-time cluster one shared num (force_sync_group_id
// always 0), and the client renders no head markers for a group of 3+ (see the header
// comment). This rebuilds num / force_sync_group_id in place with the same clustering the
// transcoder now uses — equal final time, lane-sorted, chunks of two, chained
// force_sync_group_id — and re-points child_num at each child's renumbered spawn group.
// Everything else (ids, times, lines, types, parent/child links, max_combo_count — combo
// counting never depended on grouping) is untouched, so on a chart the current transcoder
// produced this reproduces the stored bytes exactly.
//
// Returns false (chart untouched) unless some num is shared by MORE than two notes. That
// makes it a safe no-op on current uploads AND on official-style encodings (whose num values
// differ from ours — e.g. gaps of 3 — but whose groups never exceed two).
pub fn regroup(chart: &mut JsonValue) -> bool {
// (id, time, line) per real note; the dummy header at [0] stays untouched
let notes: Vec<(i64, f64, i64)> = chart["notes"].members().skip(1).map(|n| (
n["id"].as_i64().unwrap_or(0),
n["time"].as_f64().unwrap_or(0.0),
n["line"].as_i64().unwrap_or(0)
)).collect();
// Only a pre-pairing chart (some num shared 3+ ways) is rewritten
let mut group_sizes: Vec<(i64, i64)> = Vec::new();
for data in chart["notes"].members().skip(1) {
let num = data["num"].as_i64().unwrap_or(0);
match group_sizes.iter_mut().find(|(n, _)| *n == num) {
Some((_, count)) => *count += 1,
None => group_sizes.push((num, 1))
}
}
if group_sizes.iter().all(|(_, count)| *count <= 2) {
return false;
}
// Time order; ids break ties (transcode issues them in time order, so this reproduces
// the emission order the grouping pass originally saw)
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by(|a, b| notes[*a].1.total_cmp(&notes[*b].1).then(notes[*a].0.cmp(&notes[*b].0)));
// id -> (new num, new force_sync_group_id)
let mut assigned: Vec<(i64, i64, i64)> = Vec::with_capacity(notes.len());
let mut num = 100;
let mut start = 0;
while start < order.len() {
let mut end = start + 1;
while end < order.len() && simultaneous(notes[order[end]].1, notes[order[start]].1) {
end += 1;
}
let mut cluster: Vec<usize> = order[start..end].to_vec();
cluster.sort_by_key(|index| notes[*index].2);
let mut prev_num = 0;
for chunk in cluster.chunks(2) {
num += 1;
for index in chunk {
assigned.push((notes[*index].0, num, prev_num));
}
prev_num = num;
}
start = end;
}
let lookup = |id: i64| assigned.iter().find(|(i, _, _)| *i == id).map(|(_, n, f)| (*n, *f));
for data in chart["notes"].members_mut().skip(1) {
let Some((new_num, force)) = lookup(data["id"].as_i64().unwrap_or(0)) else { continue; };
data["num"] = new_num.into();
data["force_sync_group_id"] = force.into();
let child = data["child_id"].as_i64().unwrap_or(0);
if child != 0 {
// child_num names the child's spawn group and must follow its new num
data["child_num"] = lookup(child).map(|(n, _)| n).unwrap_or(0).into();
}
}
true
}
// Test helper: fabricates what pre-pairing servers stored, by squashing a current chart back
// to the OLD encoding — one shared num per equal-time cluster, force_sync_group_id 0, and
// child_num following. Lives outside the tests module so the migration tests in
// router/custom_song.rs can build realistic pre-fix fixtures from transcode output.
#[cfg(test)]
pub fn squash_to_pre_fix(chart: &mut JsonValue) {
let notes: Vec<(i64, f64)> = chart["notes"].members().skip(1)
.map(|n| (n["id"].as_i64().unwrap(), n["time"].as_f64().unwrap()))
.collect();
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by(|a, b| notes[*a].1.total_cmp(&notes[*b].1).then(notes[*a].0.cmp(&notes[*b].0)));
let mut nums: Vec<(i64, i64)> = Vec::new();
let mut num = 100;
let mut last_time = f64::NEG_INFINITY;
for index in order {
if notes[index].1 != last_time {
num += 1;
last_time = notes[index].1;
}
nums.push((notes[index].0, num));
}
let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0);
for data in chart["notes"].members_mut().skip(1) {
data["num"] = lookup(data["id"].as_i64().unwrap()).into();
data["force_sync_group_id"] = 0.into();
let child = data["child_id"].as_i64().unwrap_or(0);
if child != 0 {
data["child_num"] = lookup(child).into();
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -830,6 +757,56 @@ mod tests {
assert_eq!(combo, 3); assert_eq!(combo, 3);
} }
#[test]
fn repeated_same_lane_checkpoints_count_like_the_client() {
for final_effect in [11, 14] {
let mut beatmap = jzon::array![];
for i in 0..13 {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, 5,
if i == 12 { final_effect } else { 11 }, 0.0, 500)).unwrap();
}
let (chart, combo) = transcode(&beatmap).unwrap();
// The head shares the first checkpoint's result; all twelve
// non-root points still have their own runtime judgment.
assert_eq!(combo, 12);
assert_eq!(chart["max_combo_count"], 12);
assert_eq!(chart["notes"].len(), 14);
}
}
#[test]
fn combo_count_preserves_hold_and_cross_lane_rules() {
for (positions, expected) in [
(vec![5, 5], 1),
(vec![5, 5, 5], 2),
(vec![5, 5, 6], 2),
(vec![4, 5, 5], 2),
(vec![4, 5, 5, 5], 3),
(vec![4, 5, 6], 3),
] {
let mut beatmap = jzon::array![];
for (i, position) in positions.iter().enumerate() {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, *position, 11, 0.0, 500)).unwrap();
}
let (_, combo) = transcode(&beatmap).unwrap();
assert_eq!(combo, expected, "chain {:?}", positions);
}
}
#[test]
fn combo_repair_rejects_invalid_links() {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, 3, 1.0)]).unwrap();
for (field, value) in [("id", 2), ("parent_id", 999), ("child_id", 999), ("child_id", 1)] {
let mut broken = chart.clone();
broken["notes"][1][field] = value.into();
assert_eq!(max_combo_count(&broken), None, "invalid {}={}", field, value);
}
let mut broken = chart;
broken["notes"][2]["parent_id"] = 0.into();
assert_eq!(max_combo_count(&broken), None);
assert_eq!(max_combo_count(&object!{}), None);
}
#[test] #[test]
fn slide_hold_needs_a_duration() { fn slide_hold_needs_a_duration() {
// The effect 3 duration check has to cover note_slide_hold too // The effect 3 duration check has to cover note_slide_hold too
@@ -867,13 +844,32 @@ mod tests {
#[test] #[test]
fn undefined_effects_fall_back_to_taps() { fn undefined_effects_fall_back_to_taps() {
// Not part of NoteEffect; a hand-authored chart must still transcode to something valid for effect in [0, 15] {
for effect in [0, 8, 9, 10] {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap(); let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect); assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect);
} }
} }
#[test]
fn bandori_flick_skill_and_slide_flick() {
for (effect, kind) in [(8, 2), (9, 3), (14, 2)] {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect);
}
let (chart, combo) = transcode(&jzon::array![
sif_slide(1.0, 7, 11, 0.0, 2),
sif_slide(1.3, 5, 14, 0.0, 2)
]).unwrap();
assert_eq!(combo, 2);
assert_eq!(chart["notes"][2]["type"], 2);
assert_eq!(chart["notes"][1]["child_id"], chart["notes"][2]["id"].clone());
let (hold, combo) = transcode(&jzon::array![sif_note(1.0, 5, 10, 0.5)]).unwrap();
assert_eq!(combo, 1);
assert_eq!(hold["notes"][1]["type"], 1);
assert_eq!(hold["notes"][2]["type"], 2);
}
// Lifted verbatim from a chart uploaded to the live server (custom song 10008, "Edelied", // Lifted verbatim from a chart uploaded to the live server (custom song 10008, "Edelied",
// exported via /custom_song/download/10008) — a swipe run of note_slide into note_slide_hold, // exported via /custom_song/download/10008) — a swipe run of note_slide into note_slide_hold,
// which is the shape that used to arrive as undifferentiated taps. Note the editor writes a // which is the shape that used to arrive as undifferentiated taps. Note the editor writes a
@@ -908,11 +904,9 @@ mod tests {
} }
} }
// The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing // Parallel holds and a nine-lane wall must retain valid spawn groups.
// transcode output reproduces the old encoding exactly (one num per cluster, no force
// links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included.
#[test] #[test]
fn regroup_restores_pre_fix_wall_and_parallel_holds() { fn transcode_wall_and_parallel_holds() {
let beatmap = jzon::array![ let beatmap = jzon::array![
sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0), sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0),
sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0), sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0),
@@ -921,23 +915,7 @@ mod tests {
sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0), sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0),
sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0) sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0)
]; ];
let (expected, _) = transcode(&beatmap).unwrap(); let (chart, _) = transcode(&beatmap).unwrap();
let mut chart = expected.clone();
squash_to_pre_fix(&mut chart);
// Sanity: the squash really is the old encoding — whole clusters share one num
assert_eq!(chart["notes"][1]["num"], 101);
assert_eq!(chart["notes"][4]["num"], 101); // all 4 hold heads
assert_eq!(chart["notes"][9]["num"], 103);
assert_eq!(chart["notes"][17]["num"], 103); // all 9 wall notes
assert_eq!(chart["notes"][1]["child_num"], 102, "squashed child_num must follow");
for data in chart["notes"].members() {
assert_eq!(data["force_sync_group_id"], 0);
}
assert!(regroup(&mut chart), "a squashed chart must be rewritten");
assert_eq!(jzon::stringify(chart.clone()), jzon::stringify(expected.clone()),
"regroup must reproduce the current transcoder's output exactly");
// Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the // Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the
// previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111) // previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111)
@@ -964,37 +942,6 @@ mod tests {
} }
} }
#[test]
fn regroup_is_a_no_op_on_current_encoding() {
let beatmap = jzon::array![
sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0), sif_note(1.0, 8, 1, 0.0),
sif_note(2.0, 4, 3, 1.5)
];
let (chart, _) = transcode(&beatmap).unwrap();
let before = jzon::stringify(chart.clone());
let mut chart = chart;
assert!(!regroup(&mut chart));
assert_eq!(jzon::stringify(chart), before);
}
// Official-shaped encodings (1132_5_Sn t=9.781: num gaps of 3, force_sync naming the other
// pair) have groups of at most two and must never be "normalized" to our num sequence
#[test]
fn regroup_is_a_no_op_on_official_shaped_charts() {
let mut chart = object!{
"max_lane": 9, "sound_name": "", "max_combo_count": 4,
"notes": [
{"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 45, "num": 145, "line": 0, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 46, "num": 145, "line": 1, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 47, "num": 148, "line": 7, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145},
{"id": 48, "num": 148, "line": 8, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145}
]
};
let before = jzon::stringify(chart.clone());
assert!(!regroup(&mut chart));
assert_eq!(jzon::stringify(chart), before);
}
#[test] #[test]
fn rejects_bad_charts() { fn rejects_bad_charts() {
-121
View File
@@ -1,121 +0,0 @@
use std::fs;
use super::{chart, database, song_path, asset_meta, LEVEL_COUNT};
use crate::runtime::get_data_path;
// One-time, idempotent startup migration for charts transcoded before the spawn-group pairing
// rule (chart.rs header): the old transcoder gave every note of an equal-time cluster ONE
// shared num, and the client creates no head markers for a group of 3+ notes
// (LiveMarkerControl.CreateMarkerUI plain-returns on count > 2), so 3+ simultaneous notes
// were judged but never rendered. Stored transcoded charts are derived data with everything
// the regroup needs (time + line per note), so they are rewritten in place — no original
// upload required, which also covers songs from before export support.
//
// The same pass also corrects the stored catalog values that were fabricated rather than
// derived from official masterdata (see below): the looping PLAY cue, the per-song score-rank
// thresholds and the combo-mission targets.
//
// For each song directory with a catalog row, every chart with an over-shared num is
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
// re-download the fixed chart on their next catalog sync. The revision is bumped ONCE if
// anything changed. Charts the current transcoder produced (and official-shaped encodings)
// are left byte-identical, so re-running every boot is free.
pub fn run() {
if super::disabled() {
return;
}
let Ok(entries) = fs::read_dir(get_data_path("custom_songs")) else {
// No custom_songs directory yet - nothing was ever uploaded
return;
};
let mut music_ids: Vec<i64> = entries.flatten()
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<i64>().ok())
.collect();
music_ids.sort();
let mut songs_changed = 0;
let mut charts_changed = 0;
for music_id in music_ids {
// A song directory without a catalog row is never served; leave it alone
let Some(mut song) = database::get_song(music_id) else { continue; };
let mut changed = false;
// Catalogs written before cue_json took an is_loop argument marked BOTH cues as loop
// cues. A looping PLAY cue never reports playback-end to the client, and the live's end
// trigger (LiveTimeController: isMusicEnded -> InLiveDelay -> EndWait) hangs off exactly
// that, so those lives never finish. Only the metadata changes - the ogg bytes and their
// md5 are untouched, so no re-download is needed, just a catalog resync.
if song["sound"]["play"]["is_loop"] == true {
song["sound"]["play"]["is_loop"] = false.into();
song["sound"]["play"]["loop_end_sec"] = 0.0.into();
changed = true;
println!("Custom song {}: play cue un-looped (pre-fix catalog)", music_id);
}
// Score-rank thresholds and combo-mission targets used to be invented per song. Both are
// objective masterdata: the official live rows all carry ONE score tuple, and every
// live_mission_combo row is round(hardest full combo * 0.2/0.4/0.6/0.8) (see
// default_scores / mission_combo). Recompute them the way an edit would, so songs
// uploaded before the fix stop handing out rank S for free and stop asking for a literal
// full combo on the fourth combo mission.
let (score, multi_score) = super::default_scores();
if song["score"] != score || song["multi_score"] != multi_score {
song["score"] = score;
song["multi_score"] = multi_score;
changed = true;
println!("Custom song {}: score rank thresholds reset to the official values", music_id);
}
// Same "hardest difficulty" rule as upload/edit: the last level entry, which both write
// in ascending level order
if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) {
let missions = super::mission_combo(hardest);
if song["mission_combo"] != missions {
song["mission_combo"] = missions;
changed = true;
println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id);
}
}
for level in 1..=LEVEL_COUNT {
let path = song_path(music_id, &format!("chart_{}.json", level));
let Ok(bytes) = fs::read(&path) else { continue; };
let Ok(mut chart_data) = jzon::parse(&String::from_utf8_lossy(&bytes)) else {
println!("Custom song {} chart {}: not valid JSON, migration skipped", music_id, level);
continue;
};
if !chart::regroup(&mut chart_data) {
continue;
}
let new_bytes = jzon::stringify(chart_data);
if let Err(e) = fs::write(&path, &new_bytes) {
println!("Custom song {} chart {}: rewrite failed ({}), migration skipped", music_id, level, e);
continue;
}
// The catalog md5/size must follow the served bytes or the client's
// download-and-verify loop would never accept the asset
let (md5, size) = asset_meta(new_bytes.as_bytes());
for entry in song["levels"].members_mut() {
if entry["level"] == level {
entry["md5"] = md5.clone().into();
entry["size"] = size.into();
}
}
changed = true;
charts_changed += 1;
println!("Custom song {}: regrouped chart level {} (pre-pairing spawn groups)", music_id, level);
}
if changed {
database::update_song(music_id, &song);
songs_changed += 1;
}
}
if songs_changed > 0 {
database::bump_revision();
println!("Custom song spawn-group migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed);
}
}
+3
View File
@@ -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() {
+1
View File
@@ -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);
+54 -10
View File
@@ -45,19 +45,36 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "8fcb61a08e69d854438c4f318d38cabd", latest: false }, AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "8fcb61a08e69d854438c4f318d38cabd", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "65bb39a0030620d9720edb5e65d31ac3", latest: false }, AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "65bb39a0030620d9720edb5e65d31ac3", latest: false },
// Re-written client versions 2.5.0 - // Re-written client versions 2.5.0
AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: true }, AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true }, AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: true }, AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: false },
// Re-written client versions 2.6.0 - 2.6.1
AssetVersion { region: "JP", platform: "Windows", version: "7c74b0df372a460d82378e55dc456260", hash: "8941d32738752c4ca932a28d866852ba", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "7c74b0df372a460d82378e55dc456260", hash: "f5ac6238570d8ff8351fda9c79e8774a", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "7c74b0df372a460d82378e55dc456260", hash: "b77459c89ec94acc4fddc787ded62b95", latest: false },
// Re-written client versions 2.7.0 -
AssetVersion { region: "JP", platform: "Windows", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4cbd26690102b6b98f5886263bdfefcd", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4e64250bb32af39247fe7d7cdb840982", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "96615485374096183e4e0ce900671910", latest: true },
AssetVersion { region: "JP", platform: "Linux", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true }, AssetVersion { region: "JP", platform: "Linux", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
AssetVersion { region: "JP", platform: "macOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true }, AssetVersion { region: "JP", platform: "macOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true }, //AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
]; ];
impl AssetVersion { impl AssetVersion {
fn accepts_override(&self) -> bool {
// The retired GL clients can still use a custom version/hash, but they are not
// current releases. Keep override eligibility separate from update policy.
self.latest || (self.region == "GL"
&& self.version == "5260ff15dff8ba0c00ad91400f515f55")
}
fn stock_hash(&self) -> String { fn stock_hash(&self) -> String {
if self.latest && self.platform == "Android" && get_easter_mode() { if self.latest && self.platform == "Android" && get_easter_mode() {
if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) { if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) {
@@ -69,7 +86,11 @@ impl AssetVersion {
fn override_pair(&self) -> Option<(String, String)> { fn override_pair(&self) -> Option<(String, String)> {
let args = crate::get_args(); let args = crate::get_args();
let ov = args.asset_version.as_str(); let ov = if self.region == "GL" {
args.en_asset_version.as_str()
} else {
args.asset_version.as_str()
};
let oh = match (self.region, self.platform) { let oh = match (self.region, self.platform) {
("JP", "Windows") => args.windows_asset_hash.as_str(), ("JP", "Windows") => args.windows_asset_hash.as_str(),
("JP", "Linux") => args.linux_asset_hash.as_str(), ("JP", "Linux") => args.linux_asset_hash.as_str(),
@@ -90,15 +111,18 @@ impl AssetVersion {
} }
fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> { fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> {
let args = crate::get_args();
let mut out = Vec::new(); let mut out = Vec::new();
for entry in ASSET_VERSIONS { for entry in ASSET_VERSIONS {
if entry.platform != platform { if entry.platform != platform {
continue; continue;
} }
if entry.version == asset_version && !entry.hash.is_empty() { if entry.version == asset_version && !entry.hash.is_empty() {
if entry.latest || !args.force_updates {
out.push(entry.stock_hash()); out.push(entry.stock_hash());
} }
if entry.latest { }
if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() { if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version { if ov == asset_version {
out.push(oh); out.push(oh);
@@ -115,7 +139,7 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
if entry.platform != platform { if entry.platform != platform {
continue; continue;
} }
if entry.latest { if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() { if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version { if ov == asset_version {
return Some(oh); return Some(oh);
@@ -155,7 +179,7 @@ pub fn get_player_region(asset_version: &str) -> Option<String> {
if entry.version == asset_version { if entry.version == asset_version {
return Some(entry.region.to_string()); return Some(entry.region.to_string());
} }
if entry.latest { if entry.accepts_override() {
if let Some((ov, _)) = entry.override_pair() { if let Some((ov, _)) = entry.override_pair() {
if ov == asset_version { if ov == asset_version {
return Some(entry.region.to_string()); return Some(entry.region.to_string());
@@ -400,6 +424,10 @@ pub fn send(mut data: JsonValue, uid: i64, req: &HttpRequest) -> HttpResponse {
//println!("{}", jzon::stringify(data.clone())); //println!("{}", jzon::stringify(data.clone()));
data["server_time"] = set_time(data["server_time"].as_u64().unwrap_or(0), uid, true).into(); data["server_time"] = set_time(data["server_time"].as_u64().unwrap_or(0), uid, true).into();
if data["data"].is_object() {
super::mission::filter_response(&mut data["data"], req.headers());
}
if !data["data"]["item_list"].is_empty() || !data["data"]["updated_value_list"]["item_list"].is_empty() { if !data["data"]["item_list"].is_empty() || !data["data"]["updated_value_list"]["item_list"].is_empty() {
items::check_for_region(&mut data, req.headers()); items::check_for_region(&mut data, req.headers());
} }
@@ -484,6 +512,22 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
mod platform_tests { mod platform_tests {
use super::*; use super::*;
#[test]
fn host_version_overrides_keep_jp_and_global_distinct() {
crate::runtime::apply_config_json(
r#"{"assetVersion":"jp-test-version","enAssetVersion":"gl-test-version","jpAndroidAssetHash":"jp-test-hash","enAndroidAssetHash":"gl-test-hash"}"#,
);
assert_eq!(get_player_region("jp-test-version").as_deref(), Some("JP"));
assert_eq!(get_player_region("gl-test-version").as_deref(), Some("GL"));
assert_eq!(preferred_hash("jp-test-version", "Android").as_deref(), Some("jp-test-hash"));
assert_eq!(preferred_hash("gl-test-version", "Android").as_deref(), Some("gl-test-hash"));
assert!(valid_hashes("gl-test-version", "Android").contains(&"gl-test-hash".to_string()));
assert!(!ASSET_VERSIONS.iter().any(|entry| entry.region == "GL" && entry.latest));
crate::runtime::apply_config_json("{}");
}
#[test] #[test]
fn standalone_player_platforms_are_known() { fn standalone_player_platforms_are_known() {
assert_eq!(parse_platform("WindowsPlayer"), "Windows"); assert_eq!(parse_platform("WindowsPlayer"), "Windows");
+5
View File
@@ -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"
+13 -5
View File
@@ -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 -13
View File
@@ -422,17 +422,6 @@ pub fn update_mission_status(master_mission_id: i64, expire: u64, completed: boo
None None
} }
pub fn update_mission_status_multi(master_mission_id: JsonValue, expire: u64, completed: bool, claimed: bool, advance: i64, missions: &mut JsonValue) -> JsonValue {
let mut rv = array![];
for mission in master_mission_id.members() {
let val = update_mission_status(mission.as_i64().unwrap(), expire, completed, claimed, advance, missions);
if let Some(val2) = val {
rv.push(val2).unwrap();
}
}
rv
}
pub fn get_mission_status(id: i64, missions: &JsonValue) -> JsonValue { pub fn get_mission_status(id: i64, missions: &JsonValue) -> JsonValue {
for mission in missions.members() { for mission in missions.members() {
if mission["master_mission_id"].as_i64().unwrap() == id { if mission["master_mission_id"].as_i64().unwrap() == id {
@@ -545,8 +534,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() {
+13
View File
@@ -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());
+3
View File
@@ -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;
+230 -6
View File
@@ -3,6 +3,29 @@ use actix_web::{web, HttpRequest, Responder};
use crate::router::{global, userdata, items, databases, Login, Session, Api}; use crate::router::{global, userdata, items, databases, Login, Session, Api};
pub fn filter_response(data: &mut JsonValue, headers: &actix_web::http::header::HeaderMap) {
if items::get_region(headers) { return; }
lazy_static::lazy_static! {
static ref GLOBAL_IDS: std::collections::HashSet<i64> =
databases::csv::table(databases::csv::Region::En, "mission")
.members().filter_map(|row| row["id"].as_i64()).collect();
}
// Only project the response. Shared JP/global accounts keep their progress.
for field in ["mission_list", "clear_mission_ids"] {
if !data[field].is_array() { continue; }
for i in (0..data[field].len()).rev() {
let id = if field == "mission_list" { data[field][i]["master_mission_id"].as_i64() }
else { data[field][i].as_i64() };
if !id.is_some_and(|id| GLOBAL_IDS.contains(&id)) {
data[field].array_remove(i);
}
}
}
if data["updated_value_list"].is_object() {
filter_response(&mut data["updated_value_list"], headers);
}
}
pub fn routes(cfg: &mut web::ServiceConfig) { pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/mission") web::scope("/mission")
@@ -12,6 +35,161 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
); );
} }
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn mission_endpoint_filters_global_wire_response_not_saved_account() {
let _test = crate::runtime::lock_test_data_path();
let (uid, key) = userdata::starter::create("Regional missions test").unwrap();
let mut user = userdata::get_acc(&key);
user["tutorial_step"] = 999.into();
userdata::save_acc(&key, user);
for version in ["5260ff15dff8ba0c00ad91400f515f55", "4c921d2443335e574a82e04ec9ea243c"] {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-user-id", uid.to_string()))
.insert_header(("aoharu-asset-version", version)).to_http_request();
let response = mission(Login(key.clone())).await.respond_to(&request);
let body = actix_web::body::to_bytes(response.into_body()).await.ok().unwrap();
let plain = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
let response = jzon::parse(&plain).unwrap();
assert_eq!(response["code"], 0);
let missions = &response["data"]["mission_list"];
assert!(missions.members().any(|m| m["master_mission_id"] == 1073001));
assert_eq!(missions.members().any(|m| m["master_mission_id"] == 1073037),
items::get_region(request.headers()));
assert!(userdata::get_acc_missions(&key).members()
.any(|m| m["master_mission_id"] == 1073037));
}
}
#[test]
fn global_missions_match_client_masterdata_without_losing_saved_progress() {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-asset-version", "5260ff15dff8ba0c00ad91400f515f55"))
.to_http_request();
assert!(!items::get_region(request.headers()));
let mut saved = array![];
super::super::story::refresh(&object! {live_list: []}, &mut saved);
let original = saved.clone();
let ids = databases::csv::table(databases::csv::Region::En, "mission");
let known: std::collections::HashSet<_> = ids.members()
.filter_map(|r| r["id"].as_i64()).collect();
let absent: Vec<_> = saved.members().filter_map(|m| {
let id = m["master_mission_id"].as_i64().unwrap();
(!known.contains(&id)).then_some(id)
}).collect();
assert_eq!(absent.len(), 30);
assert!(absent.iter().all(|id| (1073037..=1073066).contains(id)));
let expected: JsonValue = saved.members().filter(|m|
known.contains(&m["master_mission_id"].as_i64().unwrap()))
.cloned().collect::<Vec<_>>().into();
let mut response = object! {mission_list: saved.clone(),
clear_mission_ids: [1073037, 1073001, 1073066],
updated_value_list: {mission_list: saved.clone(), clear_mission_ids: [1073001, 1073037]},
reward_list: [{type: 1, amount: 100}]};
filter_response(&mut response, request.headers());
assert_eq!(response["mission_list"], expected);
assert_eq!(response["updated_value_list"]["mission_list"], expected);
assert_eq!(response["clear_mission_ids"], array![1073001]);
assert_eq!(response["updated_value_list"]["clear_mission_ids"], array![1073001]);
assert_eq!(response["reward_list"], array![object! {type: 1, amount: 100}]);
assert_eq!(saved, original);
let mut unrelated = object! {item_list: [], value: 42};
let before = unrelated.clone();
filter_response(&mut unrelated, request.headers());
assert_eq!(unrelated, before);
let mut jp = object! {mission_list: saved};
let before = jp.clone();
filter_response(&mut jp, actix_web::test::TestRequest::default().to_http_request().headers());
assert_eq!(jp, before);
}
#[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 +203,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());
super::story::refresh(&user, &mut missions);
super::beginner_mission::refresh_account(&user, &mut missions);
userdata::save_acc_missions(&key, missions.clone()); userdata::save_acc_missions(&key, missions.clone());
}
Api(Some(object!{ Api(Some(object!{
"mission_list": missions "mission_list": missions
@@ -39,6 +218,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 +237,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 +253,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 +289,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 +335,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 +397,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![],
+1
View File
@@ -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();
+2
View File
@@ -104,6 +104,7 @@ impl Value {
} }
} }
#[allow(dead_code)]
pub fn as_str(&self) -> Option<&str> { pub fn as_str(&self) -> Option<&str> {
match self { match self {
Value::Str(v) => Some(v), Value::Str(v) => Some(v),
@@ -148,6 +149,7 @@ impl Map {
self.get(key).and_then(Value::as_int) self.get(key).and_then(Value::as_int)
} }
#[allow(dead_code)]
pub fn get_str(&self, key: &str) -> Option<&str> { pub fn get_str(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str) self.get(key).and_then(Value::as_str)
} }
+251 -30
View File
@@ -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(());
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))
}
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) {
let mut counts = std::collections::HashMap::<i64, i64>::new();
for live in user["live_list"].members() {
let song = &databases::MUSIC[live["master_live_id"].to_string()];
if !song.is_null() {
*counts.entry(song["bandCategory"].as_i64().unwrap_or(0)).or_default()
+= live["clear_count"].as_i64().unwrap_or(0);
}
}
for (_, master) in databases::MISSION_LIST.entries() {
let condition = master["conditionType"].as_i64().unwrap_or(0);
if !matches!(condition, 73 | 78) { continue; }
let id = master["id"].as_i64().unwrap();
let value = master["conditionValues"][0].as_i64().unwrap_or(0);
let count = if condition == 73 { counts.get(&value).copied().unwrap_or(0) }
else { super::event::get_points(value as u32, user) };
if condition == 78 && count == 0 { continue; }
set_progress(id, count, missions);
}
}
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);
}
async fn read(req: HttpRequest, Session { key, body }: Session) -> Api {
let _read = crate::lock_onto_mutex!(READ_LOCK);
let Some(part_id) = body["master_story_part_id"].as_i64() else { return Api(None); };
let part = &databases::STORY[part_id.to_string()];
let Some(story_id) = part["masterStoryId"].as_i64() else { return Api(None); };
let mut user = userdata::get_acc(&key); let mut user = userdata::get_acc(&key);
let part = body["master_story_part_id"].as_i64().unwrap(); let empty = || object! {gift_list: [], updated_value_list: [], reward_list: [], clear_mission_ids: []};
let master_id = databases::STORY[part.to_string()]["masterStoryId"].as_i64().unwrap(); if has_read(&user, story_id, part_id) { return Api(Some(empty())); }
if let Some(previous) = databases::STORY.entries().map(|(_, p)| p)
let index = user["story_list"].members().position(|r| r["master_story_id"] == master_id); .filter(|p| p["masterStoryId"] == story_id && p["number"].as_i64() < part["number"].as_i64())
.max_by_key(|p| p["number"].as_i64()) {
if index.is_none() { if !has_read(&user, story_id, previous["id"].as_i64().unwrap()) { return Api(None); }
user["story_list"].push(object!{ }
master_story_id: master_id, let mut missions = userdata::get_acc_missions(&key);
master_story_part_ids: [] 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(); }).unwrap();
} }
if !chapters.is_empty() {
for story in user["story_list"].members_mut() { response["updated_value_list"] = object! {master_chat_room_ids: rooms, master_chat_chapter_ids: chapters};
if story["master_story_id"] == master_id && !story["master_story_part_ids"].contains(part) {
story["master_story_part_ids"].push(part).unwrap();
} }
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());
} }
userdata::save_acc(&key, user.clone());
Api(Some(object!{
"gift_list":[],
"updated_value_list":{
"story_list": user["story_list"].clone()
},
"reward_list":[],
"clear_mission_ids":[]
}))
} }
+96 -5
View File
@@ -17,6 +17,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
.route("/migration", web::post().to(migration)) .route("/migration", web::post().to(migration))
.route("/gglrequestmigrationcode", web::post().to(request_migration_code)) .route("/gglrequestmigrationcode", web::post().to(request_migration_code))
.route("/gglverifymigrationcode", web::post().to(verify_migration_code)) .route("/gglverifymigrationcode", web::post().to(verify_migration_code))
.route("/migration/card/list", web::post().to(migration_card_list))
.route("/migration/card/link", web::post().to(migration_card_link))
.route("/migration/card/unlink", web::post().to(migration_card_unlink))
.route("/getregisteredplatformlist", web::post().to(getregisteredplatformlist)) .route("/getregisteredplatformlist", web::post().to(getregisteredplatformlist))
.route("/sif/migrate", web::post().to(sif_migrate)) .route("/sif/migrate", web::post().to(sif_migrate))
.route("/ss/migrate", web::post().to(sifas_migrate)) .route("/ss/migrate", web::post().to(sifas_migrate))
@@ -45,6 +48,9 @@ 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": {
@@ -52,7 +58,7 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
"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
})) }))
} }
@@ -167,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());
@@ -178,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);
@@ -192,9 +236,11 @@ pub async fn announcement(Login(key): Login) -> impl Responder {
})) }))
} }
async fn get_migration_code(Body(body): Body) -> impl Responder { async fn get_migration_code(Session { key, .. }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
let Some(user_id) = body["user_id"].as_i64() else { return Api(None); }; if user_id == 0 {
return Api(None);
}
let code = userdata::user::migration::get_acc_token(user_id); let code = userdata::user::migration::get_acc_token(user_id);
Api(Some(object!{ Api(Some(object!{
@@ -207,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![]))
} }
@@ -240,6 +291,46 @@ async fn request_migration_code(Body(body): Body) -> impl Responder {
"twxuid": user["login_token"].to_string() "twxuid": user["login_token"].to_string()
})) }))
} }
async fn migration_card_list(Session { key, .. }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
Api(Some(object!{
"card_ids": userdata::user::migration::cards_of_account(user_id)
}))
}
async fn migration_card_link(Session { key, body }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
match userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), user_id) {
Ok((card_id, rebound)) => Api(Some(object!{
"card_id": card_id,
"rebound": rebound
})),
Err(_) => Api(None)
}
}
async fn migration_card_unlink(Session { key, body }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
let Some(card_id) = userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
return Api(None);
};
if !userdata::user::migration::remove_card_of(&card_id, user_id) {
return Api(None);
}
Api(Some(object!{
"card_id": card_id
}))
}
async fn migration(Body(body): Body) -> impl Responder { async fn migration(Body(body): Body) -> impl Responder {
let user = userdata::get_name_and_rank(body["user_id"].to_string().parse::<i64>().unwrap()); let user = userdata::get_name_and_rank(body["user_id"].to_string().parse::<i64>().unwrap());
+9 -1
View File
@@ -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")
+193 -8
View File
@@ -12,36 +12,116 @@ use base64::{Engine as _, engine::general_purpose};
fn generate_token() -> String { fn generate_token() -> String {
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::rng(); let mut rng = rand::rng();
loop {
let random_string: String = (0..16) let random_string: String = (0..16)
.map(|_| { .map(|_| {
let idx = rng.random_range(0..charset.len()); let idx = rng.random_range(0..charset.len());
charset.chars().nth(idx).unwrap() charset.chars().nth(idx).unwrap()
}) })
.collect(); .collect();
random_string if !random_string.chars().all(|c| c.is_ascii_digit()) {
return random_string;
}
}
} }
// A linked NESiCA card id works as the transfer code; the password is still required
pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue { pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
let database = userdata::get_userdata_database(); let database = userdata::get_userdata_database();
let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)); let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) {
if data.is_err() { if !verify_password(password, &hash) {
return object!{success: false}; return object!{success: false};
} }
if verify_password(password, &data.unwrap()) { database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap()
let uid: i64 = database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap(); } else {
let Some(uid) = valid_card_id(token).and_then(|card| card_user(&card)) else {
return object!{success: false};
};
let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)) else {
return object!{success: false};
};
if !verify_password(password, &hash) {
return object!{success: false};
}
uid
};
let login_token = userdata::get_login_token(uid); let login_token = userdata::get_login_token(uid);
if login_token == String::new() { if login_token == String::new() {
return object!{success: false}; return object!{success: false};
} }
return object!{success: true, login_token: login_token, user_id: uid}; object!{success: true, login_token: login_token, user_id: uid}
}
object!{success: false}
} }
// Used by gree // Used by gree
pub fn transfer_code_exists(token: &str) -> bool { pub fn transfer_code_exists(token: &str) -> bool {
let database = userdata::get_userdata_database(); let database = userdata::get_userdata_database();
database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok() database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok()
|| valid_card_id(token).and_then(|card| card_user(&card)).is_some()
}
pub fn valid_card_id(card_id: &str) -> Option<String> {
let card_id = card_id.trim().to_string();
if card_id.is_empty() || card_id.len() > 32 || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(card_id)
}
pub fn card_user(card_id: &str) -> Option<i64> {
userdata::get_userdata_database().lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
}
pub fn cards_of_account(user_id: i64) -> Vec<String> {
let Ok(conn) = rusqlite::Connection::open(userdata::get_userdata_database().get_path()) else { return Vec::new(); };
let Ok(mut stmt) = conn.prepare("SELECT card_id FROM cards WHERE user_id=?1 ORDER BY created ASC") else { return Vec::new(); };
let Ok(rows) = stmt.query_map(params!(user_id), |row| row.get::<usize, String>(0)) else { return Vec::new(); };
rows.flatten().collect()
}
pub fn account_has_card(user_id: i64) -> bool {
userdata::get_userdata_database().lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok()
}
pub fn set_card(card_id: &str, user_id: i64) {
userdata::get_userdata_database().lock_and_exec(
"INSERT INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3) ON CONFLICT(card_id) DO UPDATE SET user_id=?2",
params!(card_id, user_id, crate::router::global::timestamp() as i64)
);
}
pub fn import_card(card_id: &str, user_id: i64, created: i64) {
userdata::get_userdata_database().lock_and_exec(
"INSERT OR IGNORE INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3)",
params!(card_id, user_id, created)
);
}
pub fn remove_card(card_id: &str) {
userdata::get_userdata_database().lock_and_exec("DELETE FROM cards WHERE card_id=?1", params!(card_id));
}
pub fn remove_card_of(card_id: &str, user_id: i64) -> bool {
match card_user(card_id) {
Some(owner) if owner == user_id => {
remove_card(card_id);
true
}
_ => false
}
}
// The one rule for linking a card, whoever proved the account (game session, webui session, cabinet transfer pair)
pub fn link_card(card_id: &str, user_id: i64) -> Result<(String, bool), String> {
let Some(card) = valid_card_id(card_id) else {
return Err(String::from("That is not a usable card id"));
};
if crate::router::arcade::is_cabinet_account(user_id) {
return Err(String::from("That account belongs to an arcade cabinet"));
}
let previous = card_user(&card);
set_card(&card, user_id);
crate::router::arcade::card_relinked(&card);
Ok((card, previous.is_some_and(|p| p != user_id)))
} }
pub fn save_acc_transfer(uid: i64, password: &str) -> String { pub fn save_acc_transfer(uid: i64, password: &str) -> String {
@@ -97,6 +177,13 @@ pub fn setup_sql(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
PRIMARY KEY (user_id, token) PRIMARY KEY (user_id, token)
); );
", [])?; ", [])?;
conn.execute("
CREATE TABLE IF NOT EXISTS cards (
card_id TEXT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
created BIGINT NOT NULL
);
", [])?;
let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok(); let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok();
if is_updated { return Ok(()); } if is_updated { return Ok(()); }
println!("Upgrading migration table"); println!("Upgrading migration table");
@@ -161,3 +248,101 @@ fn legacy_verify_password(password: &str, salted_hash: &str) -> bool {
let input_hash = hasher.finalize(); let input_hash = hasher.finalize();
input_hash.as_slice() == hashed_password input_hash.as_slice() == hashed_password
} }
// whenever an ai touches this codebase it decides to write 200000 lines of tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_account_lists_and_unlinks_only_its_own_cards() {
let _lock = crate::runtime::lock_test_data_path();
let (mine, _) = userdata::starter::create("Mine").unwrap();
let (theirs, _) = userdata::starter::create("Theirs").unwrap();
assert!(cards_of_account(mine).is_empty());
set_card("7020392000000011", mine);
set_card("7020392000000012", mine);
set_card("7020392000000013", theirs);
assert_eq!(cards_of_account(mine), vec!["7020392000000011".to_string(), "7020392000000012".to_string()]);
assert_eq!(cards_of_account(theirs), vec!["7020392000000013".to_string()]);
assert!(account_has_card(mine));
assert!(!remove_card_of("7020392000000013", mine));
assert_eq!(card_user("7020392000000013"), Some(theirs));
assert!(!remove_card_of("7020392000000014", mine));
assert!(remove_card_of("7020392000000011", mine));
assert!(card_user("7020392000000011").is_none());
assert_eq!(cards_of_account(mine), vec!["7020392000000012".to_string()]);
remove_card("7020392000000012");
remove_card("7020392000000013");
assert!(!account_has_card(mine));
userdata::delete_account(mine);
userdata::delete_account(theirs);
}
#[test]
fn card_ids_are_validated_not_sanitised() {
assert_eq!(valid_card_id("0123456789012345").as_deref(), Some("0123456789012345"));
assert_eq!(valid_card_id(" 0123456789012345 ").as_deref(), Some("0123456789012345"));
assert!(valid_card_id("").is_none());
assert!(valid_card_id("0123-4567").is_none());
assert!(valid_card_id("'; DROP TABLE cards--").is_none());
assert!(valid_card_id(&"x".repeat(33)).is_none());
}
#[test]
fn link_card_repoints_a_linked_card_and_says_so() {
let _lock = crate::runtime::lock_test_data_path();
let (first, _) = userdata::starter::create("First").unwrap();
let (second, _) = userdata::starter::create("Second").unwrap();
let card = "7020392000000031";
assert!(link_card("7020-3920", first).is_err());
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
assert_eq!(link_card(card, second), Ok((card.to_string(), true)));
assert_eq!(card_user(card), Some(second));
assert!(!userdata::get_acc_from_uid(first)["error"].as_bool().unwrap_or(false));
remove_card(card);
userdata::delete_account(first);
userdata::delete_account(second);
}
#[test]
fn a_linked_card_is_a_transfer_code_with_the_password_still_required() {
let _lock = crate::runtime::lock_test_data_path();
let (player, token) = userdata::starter::create("Card Transfer").unwrap();
let card = "7020392000000021";
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
assert!(!transfer_code_exists(card));
set_card(card, player);
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
assert!(!get_acc_transfer(card, "")["success"].as_bool().unwrap());
assert!(transfer_code_exists(card));
save_acc_transfer(player, "hunter2");
let by_card = get_acc_transfer(card, "hunter2");
assert!(by_card["success"].as_bool().unwrap());
assert_eq!(by_card["user_id"].as_i64(), Some(player));
assert_eq!(by_card["login_token"].as_str(), Some(token.as_str()));
assert!(!get_acc_transfer(card, "wrong")["success"].as_bool().unwrap());
let code = get_acc_token(player);
assert!(get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap());
assert!(!code.chars().all(|c| c.is_ascii_digit()));
remove_card(card);
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
userdata::delete_account(player);
}
}
+43 -7
View File
@@ -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)
@@ -610,20 +611,18 @@ pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { pub fn link_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
if crate::router::arcade::disabled() {
return HttpResponse::NotFound().finish();
}
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
}; };
let body = jzon::parse(&body).unwrap_or(object!{}); let body = jzon::parse(&body).unwrap_or(object!{});
match crate::router::arcade::bind_card_to(body["card_id"].as_str().unwrap_or(""), uid) { match crate::router::userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), uid) {
Ok(user_id) => { Ok((card_id, rebound)) => {
let resp = object!{ let resp = object!{
result: "OK", result: "OK",
data: { data: {
user_id: user_id card_id: card_id,
rebound: rebound
} }
}; };
HttpResponse::Ok() HttpResponse::Ok()
@@ -634,6 +633,43 @@ pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse {
} }
} }
pub fn list_nesica_cards(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
let resp = object!{
result: "OK",
data: {
card_ids: crate::router::userdata::user::migration::cards_of_account(uid)
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn unlink_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
let body = jzon::parse(&body).unwrap_or(object!{});
let Some(card) = crate::router::userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
return error("That is not a usable card id");
};
if !crate::router::userdata::user::migration::remove_card_of(&card, uid) {
return error("That card is not linked to this account");
}
let resp = object!{
result: "OK",
data: {
card_id: card
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req); let token = get_login_token(&req);
if token.is_none() { if token.is_none() {
+131
View File
@@ -23,10 +23,31 @@ pub struct HostConfig {
pub port: u16, pub port: u16,
pub jp_android_asset_hash: String, pub jp_android_asset_hash: String,
pub en_android_asset_hash: String, pub en_android_asset_hash: String,
pub asset_version: String,
pub en_asset_version: String,
pub jp_ios_asset_hash: String,
pub en_ios_asset_hash: String,
pub windows_asset_hash: String,
pub enable_custom_songs: bool, pub enable_custom_songs: bool,
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
pub enable_custom_3dmv: bool, pub enable_custom_3dmv: bool,
pub enable_arcade: bool, pub enable_arcade: bool,
pub nerf_custom_cards: bool,
pub owner: Vec<i64>,
pub hidden: bool,
pub force_updates: bool,
pub disable_imports: bool,
pub disable_exports: bool,
pub linux_asset_hash: String,
pub macos_asset_hash: String,
pub global_android: String,
pub japan_android: String,
pub global_ios: String,
pub japan_ios: String,
pub arcade_machine_ttl: u64,
pub arcade_session_ttl: u64,
pub image_asset_path: String,
pub masterdata: String,
} }
// Lets an embedding app (or the tests) enable the opt-in custom songs feature // Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -47,6 +68,16 @@ pub fn set_enable_arcade(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_arcade = enabled; HOST_CONFIG.write().unwrap().enable_arcade = enabled;
} }
// The custom-card nerf band. Meaningful only with enable_custom_cards; kept
// separate so an embedding app can flip them independently
pub fn set_nerf_custom_cards(enabled: bool) {
HOST_CONFIG.write().unwrap().nerf_custom_cards = enabled;
}
pub fn get_nerf_custom_cards() -> bool {
HOST_CONFIG.read().unwrap().nerf_custom_cards
}
// The --owner uids: the permission system's bootstrap grantors. Process-level // The --owner uids: the permission system's bootstrap grantors. Process-level
// state rather than db rows so they work on a fresh install and can't be // state rather than db rows so they work on a fresh install and can't be
// revoked through the webui // revoked through the webui
@@ -151,6 +182,38 @@ pub fn apply_config_json(json: &str) {
cfg.port = parsed["port"].as_u64().unwrap_or(0) as u16; cfg.port = parsed["port"].as_u64().unwrap_or(0) as u16;
cfg.jp_android_asset_hash = s("jpAndroidAssetHash"); cfg.jp_android_asset_hash = s("jpAndroidAssetHash");
cfg.en_android_asset_hash = s("enAndroidAssetHash"); cfg.en_android_asset_hash = s("enAndroidAssetHash");
cfg.asset_version = s("assetVersion");
cfg.en_asset_version = s("enAssetVersion");
cfg.jp_ios_asset_hash = s("jpIosAssetHash");
cfg.en_ios_asset_hash = s("enIosAssetHash");
cfg.windows_asset_hash = s("windowsAssetHash");
cfg.linux_asset_hash = s("linuxAssetHash");
cfg.macos_asset_hash = s("macosAssetHash");
cfg.global_android = s("globalAndroid");
cfg.japan_android = s("japanAndroid");
cfg.global_ios = s("globalIos");
cfg.japan_ios = s("japanIos");
cfg.arcade_machine_ttl = parsed["arcadeMachineTtl"].as_u64().unwrap_or(90);
cfg.arcade_session_ttl = parsed["arcadeSessionTtl"].as_u64().unwrap_or(30);
cfg.image_asset_path = s("imageAssetPath");
cfg.masterdata = s("masterdata");
cfg.owner = parsed["ownerUids"].members().filter_map(|v| v.as_i64()).collect();
cfg.hidden = parsed["hidden"].as_bool().unwrap_or(false);
cfg.force_updates = parsed["forceUpdates"].as_bool().unwrap_or(false);
cfg.disable_imports = parsed["disableImports"].as_bool().unwrap_or(false);
cfg.disable_exports = parsed["disableExports"].as_bool().unwrap_or(false);
cfg.enable_custom_songs = parsed["enableCustomSongs"].as_bool().unwrap_or(false);
cfg.enable_custom_cards = parsed["enableCustomCards"].as_bool().unwrap_or(false);
cfg.nerf_custom_cards = parsed["nerfCustomCards"].as_bool().unwrap_or(false);
cfg.enable_custom_3dmv = parsed["enableCustom3dmv"].as_bool().unwrap_or(false);
cfg.enable_arcade = parsed["enableArcade"].as_bool().unwrap_or(false);
let owners = cfg.owner.clone();
let masterdata = cfg.masterdata.clone();
let nerf_custom_cards = cfg.nerf_custom_cards;
drop(cfg);
update_owners(&owners);
update_masterdata_path(&masterdata);
set_nerf_custom_cards(nerf_custom_cards);
} }
pub fn overlay_args(args: &mut crate::options::Args) { pub fn overlay_args(args: &mut crate::options::Args) {
@@ -172,6 +235,34 @@ pub fn overlay_args(args: &mut crate::options::Args) {
} }
overlay_str!(jp_android_asset_hash); overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash); overlay_str!(en_android_asset_hash);
overlay_str!(asset_version);
overlay_str!(en_asset_version);
overlay_str!(jp_ios_asset_hash);
overlay_str!(en_ios_asset_hash);
overlay_str!(windows_asset_hash);
overlay_str!(linux_asset_hash);
overlay_str!(macos_asset_hash);
overlay_str!(global_android);
overlay_str!(japan_android);
overlay_str!(global_ios);
overlay_str!(japan_ios);
overlay_str!(image_asset_path);
overlay_str!(masterdata);
if cfg.arcade_machine_ttl != 0 {
args.arcade_machine_ttl = cfg.arcade_machine_ttl;
}
if cfg.arcade_session_ttl != 0 {
args.arcade_session_ttl = cfg.arcade_session_ttl;
}
// An embedding app may supply these options, but its default values must
// not erase flags passed by the standalone server (including Docker).
if !cfg.owner.is_empty() {
args.owner = cfg.owner.clone();
}
args.hidden |= cfg.hidden;
args.force_updates |= cfg.force_updates;
args.disable_imports |= cfg.disable_imports;
args.disable_exports |= cfg.disable_exports;
// Overlay only ever enables the features; a command-line --enable-custom-* // Overlay only ever enables the features; a command-line --enable-custom-*
// flag is never overridden back to off // flag is never overridden back to off
if cfg.enable_custom_songs { if cfg.enable_custom_songs {
@@ -186,6 +277,9 @@ pub fn overlay_args(args: &mut crate::options::Args) {
if cfg.enable_arcade { if cfg.enable_arcade {
args.enable_arcade = true; args.enable_arcade = true;
} }
if cfg.nerf_custom_cards {
args.nerf_custom_cards = true;
}
} }
// idk why an ai put tests here but they are here now. Yay tests???? // idk why an ai put tests here but they are here now. Yay tests????
@@ -212,3 +306,40 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
set_enable_arcade(true); set_enable_arcade(true);
guard guard
} }
#[cfg(test)]
#[test]
fn android_host_config_updates_owner_and_feature_options() {
use clap::Parser;
let _guard = lock_test_data_path();
apply_config_json(r#"{"ownerUids":[42,84],"hidden":true,"forceUpdates":true,"disableImports":true,"disableExports":true,"enableCustomSongs":true,"enableCustomCards":true,"nerfCustomCards":true,"enableCustom3dmv":true,"enableArcade":true,"linuxAssetHash":"linux-hash","macosAssetHash":"mac-hash","globalAndroid":"https://example.com/gl.apk","arcadeMachineTtl":45,"arcadeSessionTtl":15}"#);
let mut args = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut args);
assert_eq!(get_owners(), vec![42, 84]);
assert_eq!(args.owner, vec![42, 84]);
assert!(args.hidden && args.force_updates && args.disable_imports && args.disable_exports);
assert!(args.enable_custom_songs && args.enable_custom_cards && args.nerf_custom_cards);
assert!(get_nerf_custom_cards());
assert!(args.enable_custom_3dmv && args.enable_arcade);
assert_eq!(args.linux_asset_hash, "linux-hash");
assert_eq!(args.macos_asset_hash, "mac-hash");
assert_eq!(args.global_android, "https://example.com/gl.apk");
assert_eq!(args.arcade_machine_ttl, 45);
assert_eq!(args.arcade_session_ttl, 15);
apply_config_json(r#"{"ownerUids":[],"enableCustomSongs":false}"#);
let mut reset = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut reset);
assert!(get_owners().is_empty());
assert!(!reset.enable_custom_songs);
assert!(!reset.hidden);
assert!(!get_nerf_custom_cards());
let mut cli = crate::options::Args::parse_from([
"ew", "--force-updates", "--hidden", "--disable-imports",
"--disable-exports", "--owner", "123",
]);
overlay_args(&mut cli);
assert!(cli.force_updates && cli.hidden && cli.disable_imports && cli.disable_exports);
assert_eq!(cli.owner, vec![123]);
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maintenance</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #0b0e1a; color: #e8ebf5; font-family: system-ui, sans-serif; }
main { max-width: 32rem; padding: 2rem; text-align: center; }
h1 { font-size: 1.6rem; }
p { color: #c3c9dd; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<h1>The server is down for maintenance</h1>
<p>{{message}}</p>
</main>
</body>
</html>
+1 -1
Submodule webui updated: bea8024e80...490ccd85fc