// Arcade mode: a SIF2 client turned into a rhythm cabinet. use jzon::{object, JsonValue}; use actix_web::{web, Responder}; use crate::router::{databases, global, live, multi_live, userdata, Api, Body}; use crate::router::userdata::user::migration; use crate::database::arcade as database; const DEFAULT_MACHINE_NAME: &str = "ARCADE"; const GUEST_NAME: &str = "GUEST"; pub fn routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/arcade") .route("/info", web::get().to(info)) .route("/register", web::post().to(register)) .route("/session", web::post().to(session)) .route("/bind", web::post().to(bind)) ); } pub fn disabled() -> bool { let args = crate::get_args(); args.hidden || !args.enable_arcade } // 0 = forever fn machine_ttl_days() -> u64 { crate::get_args().arcade_machine_ttl } fn session_ttl() -> i64 { crate::get_args().arcade_session_ttl as i64 * 60 } const MAX_SESSION_WINDOWS: i64 = 4; fn flag(value: &JsonValue) -> bool { value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0 } fn card_id(body: &JsonValue) -> Option { migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) } 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); } } fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { database::live_card_session(&migration::cards_of_account(user_id), now) } fn open_card_session(card: &str, machine_id: &str) { database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl()); } async fn info() -> impl Responder { if disabled() { return Api(None); } Api(Some(object!{ "enabled": true, "machine_ttl_days": machine_ttl_days() })) } async fn register(Body(body): Body) -> impl Responder { if disabled() { return Api(None); } let name = userdata::starter::clean_name(body["name"].as_str().unwrap_or(""), DEFAULT_MACHINE_NAME); let Some((machine_user_id, machine_uuid)) = userdata::starter::create(&name) else { return Api(None); }; let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else { userdata::delete_account(machine_user_id); return Api(None); }; let machine_id = database::generate_machine_id(); database::insert_machine(&machine_id, &name, machine_user_id, guest_user_id); println!("arcade: registered machine {} \"{}\" (machine {}, guest {})", machine_id, name, machine_user_id, guest_user_id); Api(Some(object!{ "machine_id": machine_id, "machine_user_id": machine_user_id, "machine_uuid": machine_uuid, "guest_user_id": guest_user_id, "guest_uuid": guest_uuid })) } fn resolve_card(card: &str) -> Option<(i64, String)> { let user_id = migration::card_user(card)?; let uuid = userdata::get_login_token(user_id); if uuid.is_empty() { println!("arcade: card mapping pointed at missing account {} - unlinking the card", user_id); migration::remove_card(card); return None; } Some((user_id, uuid)) } async fn session(Body(body): Body) -> impl Responder { if disabled() { return Api(None); } let machine_id = body["machine_id"].as_str().unwrap_or("").to_string(); let Some(machine) = database::get_machine(&machine_id) else { println!("arcade: session for unknown machine \"{}\"", machine_id); return Api(None); }; database::touch_machine(&machine_id); let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string(); let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty(); let card = card_id(&body); if presented && card.is_none() { println!("arcade: machine {} presented an unusable card id", machine_id); return Api(None); } let Some(card) = card else { 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 { println!("arcade: machine {} has no guest account to reset", machine_id); return Api(None); }; return Api(Some(object!{ "user_id": guest_user_id, "uuid": uuid, "name": machine_name, "unlinked": false })); }; // A known card plays its own account, with every clear it has ever earned let Some((user_id, uuid)) = resolve_card(&card) else { println!("arcade: machine {} presented a card that is linked to no account", machine_id); return Api(Some(object!{ "unlinked": true })); }; open_card_session(&card, &machine_id); let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string(); Api(Some(object!{ "user_id": user_id, "uuid": uuid, "name": name, "unlinked": false })) } pub fn bind_card_to(card: &str, user_id: i64) -> Result { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); } migration::link_card(card, user_id)?; println!("arcade: card {} now plays as account {}", card, user_id); Ok(user_id) } pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); } let account = userdata::user::migration::get_acc_transfer(migration_code, pass); if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 { return Err(String::from("Transfer code and password don't match")); } let Some(user_id) = account["user_id"].as_i64() else { return Err(String::from("Transfer code and password don't match")); }; bind_card_to(card, user_id) } async fn bind(Body(body): Body) -> impl Responder { match bind_card( body["card_id"].as_str().unwrap_or(""), &body["migrationCode"].to_string(), &body["pass"].to_string() ) { Ok(user_id) => Api(Some(object!{ "user_id": user_id })), Err(reason) => { println!("arcade: bind refused - {}", reason); Api(None) } } } // lives fn cabinet_account_at(login_token: &str, now: i64) -> Option { let user_id = userdata::uid_from_login_token(login_token); if user_id == 0 { return None; } if database::machine_of_account(user_id).is_some() { return Some(user_id); } live_card_session(user_id, now).map(|_| user_id) } fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option { if !flag(&body["arcade"]) || disabled() { return None; } cabinet_account_at(login_token, now) } pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option { let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?; let Some(started) = live::get_started_live(login_token, body) else { println!("arcade: account {} ended an arcade live that was never started", user_id); return None; }; if !flag(&started["arcade"]) { println!("arcade: account {} ended a live it started as an ordinary play", user_id); return None; } Some(user_id) } fn play_machine(user_id: i64) -> Option { if let Some(machine) = database::machine_of_account(user_id) { return machine["machine_id"].as_str().map(str::to_string); } database::last_machine_of_cards(&migration::cards_of_account(user_id)) } pub fn live_started(login_token: &str, body: &JsonValue) { let now = global::timestamp() as i64; let Some(user_id) = arcade_account_at(login_token, body, now) else { return; }; if let Some(machine_id) = play_machine(user_id) { database::touch_machine(&machine_id); } if let Some((card, opened)) = live_card_session(user_id, now) { let ttl = session_ttl(); database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS)); } } pub fn live_end_body(body: &JsonValue) -> JsonValue { let mut rv = body.clone(); rv["use_lp"] = multi_live::boost_lp(1).into(); rv } fn score_rank(live_id: i64, score: i64) -> i64 { let live = &databases::LIVE_LIST[live_id.to_string()]; let mut rank = 0; for (index, key) in ["scoreC", "scoreB", "scoreA", "scoreS"].iter().enumerate() { if let Some(threshold) = live[*key].as_i64() && threshold > 0 && score >= threshold { rank = index as i64 + 1; } } rank } pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option { let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?; if disabled() { return None; } Some(user_id) } fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option { let started = live::get_started_live(login_token, body)?; if !flag(&started["arcade"]) { return None; } cabinet_account_at(login_token, now) } pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) { let Some(machine_id) = play_machine(user_id) else { return; }; let live_id = body["master_live_id"].as_i64().unwrap_or(0); let level = body["level"].as_i64().unwrap_or(0); let score = body["live_score"]["score"].as_i64().unwrap_or(0); database::insert_play(&machine_id, user_id, live_id, level, score, score_rank(live_id, score), cleared); database::touch_machine(&machine_id); } // maintenance pub fn purge_machines() -> usize { if disabled() { return 0; } let ttl = machine_ttl_days(); // 0 = never if ttl == 0 { return 0; } purge_machines_before(global::timestamp() as i64 - (ttl as i64 * 24 * 60 * 60)) } fn purge_machines_before(cutoff: i64) -> usize { let dead = database::machines_last_seen_before(cutoff); for machine in dead.members() { let machine_id = machine["machine_id"].as_str().unwrap_or(""); println!( "Removing arcade machine {} (last seen {})", machine_id, global::format_datetime(machine["last_seen"].as_u64().unwrap_or(0)) ); for key in ["machine_user_id", "guest_user_id"] { let user_id = machine[key].as_i64().unwrap_or(0); if user_id != 0 && !migration::account_has_card(user_id) { userdata::delete_account(user_id); } } database::delete_machine(machine_id); } dead.len() } // webui pub fn webui_machines() -> JsonValue { if disabled() { return jzon::array![]; } database::list_machines() } pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); } let Some(machine) = database::get_machine(machine_id) else { return Err(format!("No arcade machine {}", machine_id)); }; for key in ["machine_user_id", "guest_user_id"] { let user_id = machine[key].as_i64().unwrap_or(0); if user_id != 0 && !migration::account_has_card(user_id) { userdata::delete_account(user_id); } } database::delete_machine(machine_id); println!("arcade: machine {} removed through the webui", machine_id); Ok(()) } // Rest of file is tests #[cfg(test)] mod tests { use super::*; // The flag the client sends as 0/1 and the flag it might send as a bool are // the same flag; nothing else is #[test] fn the_arcade_flag_reads_both_shapes() { assert!(flag(&true.into())); assert!(flag(&(1).into())); assert!(!flag(&false.into())); assert!(!flag(&(0).into())); assert!(!flag(&JsonValue::Null)); assert!(!flag(&"yes".into())); } // A card id is refused rather than repaired #[test] fn card_ids_are_validated_not_sanitised() { assert_eq!(card_id(&object!{ card_id: "0123456789012345" }).as_deref(), Some("0123456789012345")); assert_eq!(card_id(&object!{ card_id: " 0123456789012345 " }).as_deref(), Some("0123456789012345")); assert!(card_id(&object!{}).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: "'; DROP TABLE cards--" }).is_none()); } // The rank stored in the ledger is the one the result screen shows, read off // the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000, // A 250000, S 350000) #[test] fn the_ledger_rank_comes_from_the_lives_own_thresholds() { assert_eq!(score_rank(1100101, 0), 0); assert_eq!(score_rank(1100101, 19_999), 0); assert_eq!(score_rank(1100101, 20_000), 1); assert_eq!(score_rank(1100101, 100_000), 2); assert_eq!(score_rank(1100101, 250_000), 3); assert_eq!(score_rank(1100101, 350_000), 4); assert_eq!(score_rank(1100101, 9_999_999), 4); // A custom song has no official row, so it has no rank assert_eq!(score_rank(10_000, 9_999_999), 0); } // A card resolves to the account it names, and to nothing at all once that // account is gone - and the dead mapping does not survive the answer #[test] fn a_card_resolves_to_its_account_and_a_dead_mapping_is_dropped() { let _lock = crate::runtime::lock_test_data_path(); use crate::database::arcade as db; // Nobody linked it: unlinked, and nothing was created for it assert!(resolve_card("7020392000000001").is_none()); assert!(migration::card_user("7020392000000001").is_none()); // Linked: the account and its token let (player, token) = userdata::starter::create("Linked").unwrap(); migration::set_card("7020392000000002", player); assert_eq!(resolve_card("7020392000000002"), Some((player, token))); // The account was deleted: unlinked from now on, mapping gone userdata::delete_account(player); assert!(resolve_card("7020392000000002").is_none()); assert!(migration::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived"); } // bind_card_to is the cabinet's entrance to the shared link rule // (migration::link_card): a cabinet's own identities are refused and a card // that already names an account is re-pointed, never refused #[test] fn bind_card_to_refuses_cabinet_identities_and_repoints_a_linked_card() { let _lock = crate::runtime::lock_test_data_path(); use crate::database::arcade as db; let (machine_account, _) = userdata::starter::create("Cabinet Bind").unwrap(); let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap(); let machine_id = db::generate_machine_id(); db::insert_machine(&machine_id, "Cabinet Bind", machine_account, guest_account); let (player, _) = userdata::starter::create("Player").unwrap(); // The id is validated, not repaired assert!(bind_card_to("0123-4567", player).is_err()); assert!(migration::card_user("0123-4567").is_none()); // Neither cabinet identity may sit behind a card let card = "4242424242424242"; assert!(bind_card_to(card, machine_account).is_err()); assert!(bind_card_to(card, guest_account).is_err()); assert!(migration::card_user(card).is_none()); // A card another player linked is re-pointed, and that player's account // is untouched - the card was the only thing that changed hands let (previous, _) = userdata::starter::create("Previous").unwrap(); migration::set_card(card, previous); assert_eq!(bind_card_to(card, player), Ok(player)); assert_eq!(migration::card_user(card), Some(player)); assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account"); 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 // /live/retire, which carries no arcade flag - the start record is what // proves it was a cabinet's - and it lands in the ledger as a failed song // rather than not at all. #[test] fn a_failed_cabinet_song_lands_in_the_ledger_uncleared() { let _lock = crate::runtime::lock_test_data_path(); use crate::database::arcade as db; let (machine_account, _) = userdata::starter::create("Cabinet Retire").unwrap(); let (guest_account, guest_token) = userdata::starter::create(GUEST_NAME).unwrap(); let machine_id = db::generate_machine_id(); db::insert_machine(&machine_id, "Cabinet Retire", machine_account, guest_account); // The credit's song: /live/start carried the flag, so the record does let start = object!{ master_live_id: 1100101, level: 4, deck_slot: 1, live_boost: 1, arcade: true }; live::start_live(&guest_token, &start); // The life gauge emptied: the client played it out and reported the score // it reached at /live/retire, which has no arcade flag of its own let retire = object!{ master_live_id: 1100101, level: 4, live_score: { score: 123_456, max_combo: 40, play_time: 93 } }; let now = global::timestamp() as i64; let user_id = retire_user_at(&guest_token, &retire, now).expect("a cabinet's retire was not recognised"); assert_eq!(user_id, guest_account); record_play(user_id, &retire, false); let ledger = db::plays_of_machine(&machine_id); assert_eq!(ledger.len(), 1); assert_eq!(ledger[0]["cleared"].as_bool(), Some(false)); assert_eq!(ledger[0]["user_id"].as_i64(), Some(guest_account)); assert_eq!(ledger[0]["live_id"].as_i64(), Some(1100101)); assert_eq!(ledger[0]["level"].as_i64(), Some(4)); assert_eq!(ledger[0]["score"].as_i64(), Some(123_456), "the retire's own score was not carried"); // It counts as a song of the credit, and only the cleared count excludes it assert!(db::list_machines().members().any(|m| m["machine_id"] == machine_id.as_str() && m["play_count"] == 1 && m["cleared_count"] == 0 )); // A live that was started as an ordinary play is not a cabinet's song, // however the retire that ends it looks live::start_live(&guest_token, &object!{ master_live_id: 1100102, level: 4, deck_slot: 1 }); assert!(retire_user_at(&guest_token, &object!{ master_live_id: 1100102, level: 4, live_score: { score: 1, max_combo: 1, play_time: 93 } }, now).is_none(), "an unflagged start was bookkept as a cabinet's song"); // Neither is a retire with no start behind it at all assert!(retire_user_at(&guest_token, &object!{ master_live_id: 1100103, level: 4, live_score: { score: 1, max_combo: 1, play_time: 93 } }, now).is_none()); db::delete_machine(&machine_id); userdata::delete_account(machine_account); userdata::delete_account(guest_account); } // A cabinet that aged out takes its two accounts with it - and nothing // else. A cabinet still in use, and any account a player bound a card to, // survives the sweep. #[test] fn an_aged_out_cabinet_takes_only_its_own_accounts() { let _lock = crate::runtime::lock_test_data_path(); use crate::database::arcade as db; // Old: unseen since before the cutoff let (old_machine, old_machine_token) = userdata::starter::create("Old cabinet").unwrap(); let (old_guest, old_guest_token) = userdata::starter::create(GUEST_NAME).unwrap(); let old_id = db::generate_machine_id(); db::insert_machine(&old_id, "Old cabinet", old_machine, old_guest); // Live: seen just now let (live_machine, live_machine_token) = userdata::starter::create("Live cabinet").unwrap(); let (live_guest, _) = userdata::starter::create(GUEST_NAME).unwrap(); let live_id = db::generate_machine_id(); db::insert_machine(&live_id, "Live cabinet", live_machine, live_guest); // Old too, but somebody bound a card to its machine account let (claimed_machine, claimed_machine_token) = userdata::starter::create("Claimed cabinet").unwrap(); let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap(); let claimed_id = db::generate_machine_id(); db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest); migration::set_card("4444333322221111", claimed_machine); // A player account with a card, belonging to no cabinet at all let (player, player_token) = userdata::starter::create("Player").unwrap(); migration::set_card("8888777766665555", player); // Everything registered above is "seen now"; only the two we push back // are older than the cutoff let now = global::timestamp() as i64; for id in [&old_id, &claimed_id] { db::backdate_machine_for_test(id, now - 1000); } assert_eq!(purge_machines_before(now - 500), 2); // The aged-out cabinet is gone, accounts and all assert!(db::get_machine(&old_id).is_none()); assert_eq!(userdata::uid_from_login_token(&old_machine_token), 0); assert_eq!(userdata::uid_from_login_token(&old_guest_token), 0); // The cabinet still in use is untouched assert!(db::get_machine(&live_id).is_some()); assert_eq!(userdata::uid_from_login_token(&live_machine_token), live_machine); // The claimed cabinet is retired, but the account behind its card is not assert!(db::get_machine(&claimed_id).is_none()); assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine); assert_eq!(migration::card_user("4444333322221111"), Some(claimed_machine)); // A card-bound player account is never a candidate in the first place assert_eq!(userdata::uid_from_login_token(&player_token), player); db::delete_machine(&live_id); } }