From 9e5228e9f50024f04b0e922c86bc05a0bd221301 Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Wed, 26 Aug 2026 08:21:29 -0500 Subject: [PATCH] wip stuff (idk) --- docker/start.sh | 7 + src/database.rs | 1 + src/database/arcade.rs | 474 +++++++++++++++++ src/lib.rs | 6 + src/options.rs | 9 + src/router.rs | 5 + src/router/arcade.rs | 928 +++++++++++++++++++++++++++++++++ src/router/live.rs | 28 +- src/router/multi_live.rs | 2 +- src/router/tools/guest.rs | 80 ++- src/router/userdata/mod.rs | 48 +- src/router/userdata/starter.rs | 465 +++++++++++++++++ src/router/webui.rs | 85 ++- 13 files changed, 2116 insertions(+), 22 deletions(-) create mode 100644 src/database/arcade.rs create mode 100644 src/router/arcade.rs create mode 100644 src/router/userdata/starter.rs diff --git a/docker/start.sh b/docker/start.sh index 8d74931..ad1d3a9 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -16,6 +16,7 @@ args=( [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) [ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv) +[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade) add_opt() { local value="$1" flag="$2" @@ -35,6 +36,12 @@ add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash # Server owner uid(s) for the permission system (comma-separated) add_opt "${OWNER:-}" --owner +# Days an unseen arcade machine survives --purge +add_opt "${ARCADE_MACHINE_TTL:-}" --arcade-machine-ttl + +# Minutes a card's credit buys LP-free play for after /api/arcade/session +add_opt "${ARCADE_SESSION_TTL:-}" --arcade-session-ttl + # Asset / image paths. add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path add_opt "${MASTERDATA:-}" --masterdata diff --git a/src/database.rs b/src/database.rs index ed8de97..0bfc520 100644 --- a/src/database.rs +++ b/src/database.rs @@ -4,3 +4,4 @@ pub mod custom_card; pub mod custom_3dmv; pub mod permissions; pub mod announcements; +pub mod arcade; diff --git a/src/database/arcade.rs b/src/database/arcade.rs new file mode 100644 index 0000000..09a0f45 --- /dev/null +++ b/src/database/arcade.rs @@ -0,0 +1,474 @@ +use lazy_static::lazy_static; +use rusqlite::params; +use jzon::{array, object, JsonValue}; +use rand::RngExt; + +use crate::router::global; +use crate::sql::SQLite; + +lazy_static! { + static ref DATABASE: SQLite = SQLite::new("arcade.db", setup_tables); +} + +// A cabinet owns exactly two accounts forever: the MACHINE account (the identity +// the attract loop and its demo lives run on) and one reusable GUEST account, +// rewritten from scratch at every credit. Neither is ever handed out twice and +// 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` +// / `last_session` are the "which cabinet is this card sitting at" record: a +// card account is not owned by any one machine, so /live/end attributes its play +// to the machine that most recently ran a session for that card. Keeping it as +// two columns on the row the session already writes is the whole design - no +// second table, no row to expire, and the attribution can never outlive the +// mapping it belongs to. +// +// `session_until` is the same row's other half and the 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; after it, the same account +// is an ordinary phone account again. It is a stamp rather than a flag so that a +// 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. +// `cleared` is 0 for a song whose life gauge emptied: the client plays it out and +// reports it at /live/retire rather than /live/end (there is no cleared flag on +// the end wire and live_end_ex records a clear unconditionally), so the retire is +// the only place the ledger can learn about a failed song. A credit's play is two +// songs whether they were passed or not, which is what makes the total the number +// an operator's bookkeeping counts. +fn setup_tables(conn: &rusqlite::Connection) { + conn.execute_batch(" +CREATE TABLE IF NOT EXISTS machines ( + machine_id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + machine_user_id BIGINT NOT NULL, + guest_user_id BIGINT NOT NULL, + created BIGINT NOT NULL, + last_seen BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS cards ( + card_id TEXT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + created BIGINT NOT NULL, + last_machine_id TEXT NOT NULL DEFAULT '', + last_session BIGINT NOT NULL DEFAULT 0, + session_until BIGINT NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS plays ( + id INTEGER PRIMARY KEY, + machine_id TEXT NOT NULL, + user_id BIGINT NOT NULL, + live_id BIGINT NOT NULL, + level INT NOT NULL, + score BIGINT NOT NULL, + rank INT NOT NULL, + at BIGINT NOT NULL, + cleared INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS plays_machine ON plays (machine_id); + ").unwrap(); + // Upgrade databases written before the card session record existed. Existing + // mappings simply have no cabinet attached until their next session. + if conn.prepare("SELECT last_machine_id FROM cards LIMIT 1;").is_err() { + println!("Upgrading arcade card table"); + conn.execute("ALTER TABLE cards ADD COLUMN last_machine_id TEXT NOT NULL DEFAULT '';", []).unwrap(); + conn.execute("ALTER TABLE cards ADD COLUMN last_session BIGINT NOT NULL DEFAULT 0;", []).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 + // 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 { + const CHARSET: &[u8] = b"0123456789abcdef"; + let mut rng = rand::rng(); + let id: String = (0..16) + .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char) + .collect(); + if machine_exists(&id) { + return generate_machine_id(); + } + id +} + +pub fn machine_exists(machine_id: &str) -> bool { + DATABASE.lock_and_select("SELECT machine_id FROM machines WHERE machine_id=?1", params!(machine_id)).is_ok() +} + +pub fn insert_machine(machine_id: &str, name: &str, machine_user_id: i64, guest_user_id: i64) { + let now = global::timestamp() as i64; + DATABASE.lock_and_exec( + "INSERT INTO machines (machine_id, name, machine_user_id, guest_user_id, created, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params!(machine_id, name, machine_user_id, guest_user_id, now) + ); +} + +fn machine_row(conn: &rusqlite::Connection, machine_id: &str) -> Option { + let mut stmt = conn.prepare("SELECT machine_id, name, machine_user_id, guest_user_id, created, last_seen FROM machines WHERE machine_id=?1").ok()?; + stmt.query_row(params!(machine_id), |row| { + Ok(object!{ + machine_id: row.get::(0)?, + name: row.get::(1)?, + machine_user_id: row.get::(2)?, + guest_user_id: row.get::(3)?, + created: row.get::(4)?, + last_seen: row.get::(5)? + }) + }).ok() +} + +pub fn get_machine(machine_id: &str) -> Option { + let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; + machine_row(&conn, machine_id) +} + +// The cabinet is alive: every session (and every play it records) stamps it, and +// the purge sweeper measures a machine's age from here +pub fn touch_machine(machine_id: &str) { + DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(global::timestamp() as i64, machine_id)); +} + +// The machine a user account belongs to, when the account IS one of a cabinet's +// own two identities. A card-bound player account is nobody's, and answers None +pub fn machine_of_account(user_id: i64) -> Option { + let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; + let mut stmt = conn.prepare("SELECT machine_id FROM machines WHERE machine_user_id=?1 OR guest_user_id=?1").ok()?; + let machine_id: String = stmt.query_row(params!(user_id), |row| row.get(0)).ok()?; + machine_row(&conn, &machine_id) +} + +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("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 machines WHERE machine_id=?1", params!(machine_id)); +} + +// Every machine with its play count, newest sighting first - the webui list +pub fn list_machines() -> JsonValue { + let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else { + return array![]; + }; + let Ok(mut stmt) = conn.prepare(" + SELECT m.machine_id, m.name, m.machine_user_id, m.guest_user_id, m.created, m.last_seen, + (SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id), + (SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id AND p.cleared <> 0) + FROM machines m ORDER BY m.last_seen DESC + ") else { + return array![]; + }; + let Ok(mapped) = stmt.query_map(params!(), |row| { + Ok(object!{ + machine_id: row.get::(0)?, + name: row.get::(1)?, + machine_user_id: row.get::(2)?, + guest_user_id: row.get::(3)?, + created: row.get::(4)?, + last_seen: row.get::(5)?, + play_count: row.get::(6)?, + cleared_count: row.get::(7)? + }) + }) else { + return array![]; + }; + let mut rv = array![]; + for row in mapped.flatten() { + rv.push(row).unwrap(); + } + rv +} + +// Machines whose last sighting is older than `cutoff`. The two account ids come +// back with them: the sweeper deletes the accounts in the same pass +pub fn machines_last_seen_before(cutoff: i64) -> JsonValue { + let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else { + return array![]; + }; + let Ok(mut stmt) = conn.prepare("SELECT machine_id, machine_user_id, guest_user_id, last_seen FROM machines WHERE last_seen < ?1") else { + return array![]; + }; + let Ok(mapped) = stmt.query_map(params!(cutoff), |row| { + Ok(object!{ + machine_id: row.get::(0)?, + machine_user_id: row.get::(1)?, + guest_user_id: row.get::(2)?, + last_seen: row.get::(3)? + }) + }) else { + return array![]; + }; + let mut rv = array![]; + for row in mapped.flatten() { + rv.push(row).unwrap(); + } + rv +} + +pub fn card_user(card_id: &str) -> Option { + 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 +// 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 +pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) { + DATABASE.lock_and_exec( + "UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4", + params!(machine_id, global::timestamp() as i64, until, card_id) + ); +} + +// The card of this account whose cabinet session is still open at `now`, as +// (card id, when the session started). None when no card of the account is at a +// cabinet - which is every phone account, and every card between credits. +pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { + let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; + let mut stmt = conn.prepare( + "SELECT card_id, last_session FROM cards WHERE user_id=?1 AND session_until>?2 ORDER BY session_until DESC LIMIT 1" + ).ok()?; + stmt.query_row(params!(user_id, now), |row| Ok((row.get::(0)?, row.get::(1)?))).ok() +} + +// A live starting inside the window pushes its end back, so a credit whose songs +// run long is not cut off mid-play. Only ever forward, and never past the +// ceiling the caller computed from the session itself: extending without a +// ceiling would turn one credit into an endless supply of LP-free lives +pub fn extend_card_session(card_id: &str, until: i64) { + DATABASE.lock_and_exec( + "UPDATE cards SET session_until=?1 WHERE card_id=?2 AND session_until Option { + let machine_id: String = DATABASE.lock_and_select_type( + "SELECT last_machine_id FROM cards WHERE user_id=?1 AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1", + params!(user_id) + ).ok()?; + if machine_id.is_empty() { + return None; + } + 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) { + 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)", + params!(machine_id, user_id, live_id, level, score, rank, global::timestamp() as i64, i64::from(cleared)) + ); +} + +// Only the sweeper's own test needs to move a cabinet's clock; production code +// only ever stamps last_seen forward through touch_machine +#[cfg(test)] +pub fn backdate_machine_for_test(machine_id: &str, last_seen: i64) { + DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(last_seen, machine_id)); +} + +pub fn play_count(machine_id: &str) -> i64 { + DATABASE.lock_and_select_type("SELECT COUNT(*) FROM plays WHERE machine_id=?1", params!(machine_id)).unwrap_or(0) +} + +// The cabinet's ledger, newest first. Only the tests read it back today - the +// webui counts rows rather than listing them - but the ledger exists to be read +pub fn plays_of_machine(machine_id: &str) -> JsonValue { + let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else { + return array![]; + }; + let Ok(mut stmt) = conn.prepare( + "SELECT user_id, live_id, level, score, rank, at, cleared FROM plays WHERE machine_id=?1 ORDER BY id DESC" + ) else { + return array![]; + }; + let Ok(mapped) = stmt.query_map(params!(machine_id), |row| { + Ok(object!{ + user_id: row.get::(0)?, + live_id: row.get::(1)?, + level: row.get::(2)?, + score: row.get::(3)?, + rank: row.get::(4)?, + at: row.get::(5)?, + cleared: row.get::(6)? != 0 + }) + }) else { + return array![]; + }; + let mut rv = array![]; + for row in mapped.flatten() { + rv.push(row).unwrap(); + } + rv +} + +#[cfg(test)] +mod tests { + use super::*; + + // A cabinet owns its two accounts, is found from either of them, and its + // last_seen is what the purge sweeper measures + #[test] + fn a_machine_owns_its_two_accounts() { + let _lock = crate::runtime::lock_test_data_path(); + + let id = generate_machine_id(); + assert_eq!(id.len(), 16); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + + insert_machine(&id, "Cabinet 1", 111_111_111_111_111, 222_222_222_222_222); + let machine = get_machine(&id).unwrap(); + assert_eq!(machine["name"].as_str(), Some("Cabinet 1")); + assert_eq!(machine["machine_user_id"].as_i64(), Some(111_111_111_111_111)); + assert_eq!(machine["guest_user_id"].as_i64(), Some(222_222_222_222_222)); + + // Either of the two identities finds the cabinet; a stranger does not + assert_eq!(machine_of_account(111_111_111_111_111).unwrap()["machine_id"].as_str(), Some(id.as_str())); + assert_eq!(machine_of_account(222_222_222_222_222).unwrap()["machine_id"].as_str(), Some(id.as_str())); + assert!(machine_of_account(999_999_999_999_999).is_none()); + + // Aged out by the sweeper's cutoff, and only then + let seen = machine["last_seen"].as_i64().unwrap(); + assert!(machines_last_seen_before(seen + 1).members().any(|m| m["machine_id"] == id.as_str())); + assert!(!machines_last_seen_before(seen).members().any(|m| m["machine_id"] == id.as_str())); + + delete_machine(&id); + assert!(get_machine(&id).is_none()); + } + + // A card names an account; a re-bind repoints it and drops the previous + // holder's cabinet record; plays are attributed to the cabinet the card last + // sat at and die with the machine + #[test] + fn a_card_names_an_account_and_its_last_cabinet() { + let _lock = crate::runtime::lock_test_data_path(); + + let machine = generate_machine_id(); + insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444); + let card = "0123456789012345"; + + 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 + assert!(last_machine_of_card_account(555_555_555_555_555).is_none()); + + let now = global::timestamp() as i64; + 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())); + + // A re-bind keeps the card, moves the account and forgets the cabinet - + // and the window the previous account's credit paid for + set_card(card, 666_666_666_666_666); + assert_eq!(card_user(card), Some(666_666_666_666_666)); + 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); + insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true); + insert_play(&machine, 666_666_666_666_666, 1100101, 4, 123_456, 2, false); + assert_eq!(play_count(&machine), 2); + // A failed song is a song: it is in the ledger and in the total, and the + // cleared count is what separates the two + assert!(list_machines().members().any(|m| + m["machine_id"] == machine.as_str() && m["play_count"] == 2 && m["cleared_count"] == 1 + )); + let ledger = plays_of_machine(&machine); + assert_eq!(ledger.len(), 2); + assert_eq!(ledger[0]["cleared"].as_bool(), Some(false)); + assert_eq!(ledger[0]["score"].as_i64(), Some(123_456)); + assert_eq!(ledger[1]["cleared"].as_bool(), Some(true)); + + // Removing the cabinet takes its ledger with it and unlinks the card + open_card_session(card, &machine, now + 60); + delete_machine(&machine); + assert_eq!(play_count(&machine), 0); + 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 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 + // not, pushed forward but never backward, and never shared with another card + // of another account + #[test] + fn a_credit_opens_a_window_that_closes_on_its_own() { + let _lock = crate::runtime::lock_test_data_path(); + + let machine = generate_machine_id(); + insert_machine(&machine, "Cabinet 3", 777_777_777_777_777, 888_888_888_888_888); + let card = "1212121212121212"; + let user = 121_212_121_212_121; + let now = global::timestamp() as i64; + + // A mapping with no session behind it is not a cabinet session + set_card(card, user); + assert!(live_card_session(user, now).is_none()); + + open_card_session(card, &machine, now + 600); + let (open_card, opened) = live_card_session(user, now).expect("the credit did not open a window"); + assert_eq!(open_card, card); + assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now); + + // The window closes by itself, with nothing to sweep + assert!(live_card_session(user, now + 599).is_some()); + assert!(live_card_session(user, now + 600).is_none(), "the window outlived its own expiry"); + assert!(live_card_session(user, now + 601).is_none()); + + // A live inside it pushes it forward, and only forward + extend_card_session(card, now + 1200); + assert!(live_card_session(user, now + 900).is_some()); + extend_card_session(card, now + 300); + assert!(live_card_session(user, now + 900).is_some(), "an extension moved the window backwards"); + + // Another account's card is untouched by any of it + let other_card = "3434343434343434"; + let other_user = 343_434_343_434_343; + set_card(other_card, other_user); + assert!(live_card_session(other_user, now).is_none()); + + delete_machine(&machine); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2533459..7e1bdfa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,12 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> { if args.purge { 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(); + if machines > 0 { + println!("Purged {} arcade machine(s)", machines); + } let ct = crate::router::userdata::purge_accounts(); println!("Purged {} accounts", ct); } diff --git a/src/options.rs b/src/options.rs index 7a6b8be..b487ddb 100644 --- a/src/options.rs +++ b/src/options.rs @@ -47,6 +47,15 @@ pub struct Args { #[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, + #[arg(long, default_value_t = false, help = "Enable arcade mode (cabinet registration, per-machine guest accounts, LP-free arcade lives). Disabled by default; every /api/arcade endpoint and webui element is hidden unless this is set")] + pub enable_arcade: bool, + + #[arg(long, default_value_t = 90, help = "Days an arcade machine may go unseen before --purge deletes it together with its machine and guest accounts")] + pub arcade_machine_ttl: u64, + + #[arg(long, default_value_t = 30, help = "Minutes a card-bound account may play LP-free after the cabinet ran /api/arcade/session for its card. Each arcade live restarts the window, up to four windows from the session itself; outside it the account is an ordinary phone account and pays LP")] + pub arcade_session_ttl: u64, + #[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")] pub owner: Vec, diff --git a/src/router.rs b/src/router.rs index f6679c2..b17c736 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,4 +1,5 @@ pub mod start; +pub mod arcade; pub mod global; pub mod login; pub mod userdata; @@ -223,6 +224,8 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/cheat" => webui::cheat(req, body), "/api/webui/grantPermission" => webui::grant_permission(req, body), "/api/webui/revokePermission" => webui::revoke_permission(req, body), + "/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body), + "/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body), _ => api_req(req, body).await } } else { @@ -242,6 +245,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/customCardLimits" => webui::custom_card_limits(req), "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/myScopes" => webui::my_scopes(req), + "/api/webui/listArcadeMachines" => webui::list_arcade_machines(req), _ => api_req(req, body).await } } @@ -261,6 +265,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .route(actix_web::web::get().to(home::gift_get)) .route(actix_web::web::post().to(user::gift)) ) + .configure(arcade::routes) .configure(card::routes) .configure(chat::routes) .configure(custom_song::routes) diff --git a/src/router/arcade.rs b/src/router/arcade.rs new file mode 100644 index 0000000..643d605 --- /dev/null +++ b/src/router/arcade.rs @@ -0,0 +1,928 @@ +// 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 actix_web::{web, Responder}; + +use crate::router::{databases, global, live, multi_live, userdata, Api, Body}; +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"; + +// 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"; + +// 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) { + 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 +} + +// Days a machine may go unseen before --purge deletes it. 0 means never. +fn machine_ttl_days() -> u64 { + 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 { + 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; + +// 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 { + 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 { + let card_id = body["card_id"].as_str().unwrap_or("").trim().to_string(); + if card_id.is_empty() { + return None; + } + if card_id.len() > MAX_CARD_ID_LEN || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(card_id) +} + +// 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) { + 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 { + if disabled() { + return Api(None); + } + Api(Some(object!{ + "enabled": true, + "machine_ttl_days": machine_ttl_days() + })) +} + +// 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 { + 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 { + // 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); + 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 + })) +} + +// One credit. Answers the account the player is about to become: the cabinet's +// guest (reset in place) or, with a card, the account that card names. +// +// `name` is the cabinet's on-screen name entry, and it is read in exactly two +// places: the account a brand new card is issued, and a rename of that same +// account while the credit that created it is still running (see +// issued_by_this_credit). A guest credit ignores it - a guest is named after its +// cabinet - and so does an ordinary card session. +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(); + + // 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 card = card_id(&body); + if presented && card.is_none() { + println!("arcade: machine {} presented an unusable card id", machine_id); + return Api(None); + } + let typed_name = body["name"].as_str().unwrap_or("").trim().to_string(); + + 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 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, + "is_new": true + })); + }; + + // A known card plays its own account, with every clear it has ever earned + if let Some(user_id) = database::card_user(&card) { + let uuid = userdata::get_login_token(user_id); + if !uuid.is_empty() { + // 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); + 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!{ + "user_id": user_id, + "uuid": uuid, + "name": name, + "is_new": true + })) +} + +// Point a card at an existing (phone) account. The proof is the data-transfer +// code and its password - the same check /api/user/gglverifymigrationcode makes +// (user.rs:214-220), through the one function that owns that comparison. +// +// Shared by the cabinet's own /api/arcade/bind and the webui account page's +// form: the browser cannot speak the game protocol (encrypted bodies behind the +// asset gate), so it gets its own entrance, not its own copy of the rule. +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 Some(card) = card_id(&object!{ "card_id": card }) else { + return Err(String::from("That is not a usable card id")); + }; + + 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")); + }; + // 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. The + // proof this bind takes - a transfer code and password - is exactly what a + // hostile client can register on a guest during its own credit. + 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); + Ok(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) + } + } +} + +// An account /api/arcade/session issued to an unknown card that was then never +// 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 { + 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); + } + database::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 { + if !flag(&body["arcade"]) || disabled() { + return None; + } + 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 { + 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) +} + +// 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 { + if let Some(machine) = database::machine_of_account(user_id) { + return machine["machine_id"].as_str().map(str::to_string); + } + database::last_machine_of_card_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) { + 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)) = database::live_card_session(user_id, now) { + let ttl = session_ttl(); + 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 { + let mut rv = body.clone(); + rv["use_lp"] = multi_live::boost_lp(1).into(); + 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 { + 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 +} + +// 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 { + // 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)?; + 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) +} + +// 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) { + 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 ------------------------------------------------------------ + +// 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 { + if disabled() { + return 0; + } + let ttl = machine_ttl_days(); + // 0 is "never age a cabinet out", not "age every cabinet out this second" + 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 && !database::account_has_card(user_id) { + userdata::delete_account(user_id); + } + } + database::delete_machine(machine_id); + } + dead.len() +} + +// -- webui ------------------------------------------------------------------ + +// The operator's machine list: name, id, last seen and how many lives the +// cabinet has recorded. +pub fn webui_machines() -> JsonValue { + if disabled() { + return jzon::array![]; + } + 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> { + 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 && !database::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(()) +} + +#[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()); + 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 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); + } + + // The card-rebind cleanup only ever takes an account that is provably a + // throwaway: a real player's account survives losing its card + #[test] + fn only_an_untouched_throwaway_follows_its_card() { + let _lock = crate::runtime::lock_test_data_path(); + + // Never played, no card, no password, not a cabinet: a throwaway + let (orphan, _) = userdata::starter::create("2345").unwrap(); + assert!(orphan_of_a_rebound_card(orphan)); + + // Somebody played on it + let (played, played_token) = userdata::starter::create("2346").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); + assert!(!orphan_of_a_rebound_card(played)); + + // Somebody can take it over from a phone + let (phone, _) = userdata::starter::create("2347").unwrap(); + userdata::user::migration::save_acc_transfer(phone, "hunter2"); + assert!(!orphan_of_a_rebound_card(phone)); + + // 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); + } + + // 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); + db::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(); + db::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!(db::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); + } + + // 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); + } + } +} diff --git a/src/router/live.rs b/src/router/live.rs index e5bd0e1..0f5cd09 100644 --- a/src/router/live.rs +++ b/src/router/live.rs @@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder}; use rand::RngExt; use lazy_static::lazy_static; -use crate::router::{databases, global, items, userdata, Login, Session, Api}; +use crate::router::{arcade, databases, global, items, userdata, Login, Session, Api}; use crate::router::clear_rate::live_completed; use crate::router::tools::guest; @@ -25,7 +25,17 @@ pub fn routes(cfg: &mut web::ServiceConfig) { async fn retire(Session { key, body }: Session) -> impl Responder { + // An arcade song whose life gauge emptied is played out and reported here, + // not at /live/end: there is no cleared flag on the end wire and live_end_ex + // records a clear unconditionally. So this is the only place the cabinet's + // ledger can learn about a failed song, and it has to look before live_retire + // sweeps the start record that proves the song was a cabinet's. Nothing about + // an ordinary retire changes: an unflagged start answers None here. + let arcade_user = arcade::arcade_retire_user(&key, &body); live_retire(&key, &body); + if let Some(user_id) = arcade_user { + arcade::record_play(user_id, &body, false); + } if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 { // Body-derived, so defaulted rather than unwrapped: a retire is not worth a // panicked worker either (live_completed ignores an unknown id/level). @@ -345,6 +355,10 @@ pub fn start_live(login_token: &str, body: &JsonValue) { } async fn start(Session { key, body }: Session) -> impl Responder { + // A live starting on an arcade cabinet is a sighting for that cabinet: its + // last_seen is what the TTL sweeper measures, and a machine in daily use + // must never age out from under its own players. + arcade::live_started(&key, &body); start_live(&key, &body); Api(Some(array![])) } @@ -840,6 +854,18 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool } async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder { + // "arcade": true - a credit in the cabinet already paid for this play, so LP + // is neither read nor decremented. The seam is the one /multi_live/end uses + // (live_end_ex's consume_lp), with use_lp injected as one normal 1x play, so + // every reward, the EXP, the bonds, the high score and the clear all record + // exactly as they do for a phone player. The flag is only honoured for an + // account that really belongs to a cabinet - see arcade::arcade_play_user. + if let Some(user_id) = arcade::arcade_play_user(&key, &body) { + let body = arcade::live_end_body(&body); + let rv = live_end_ex(&req, &key, &body, false, false, true); + arcade::record_play(user_id, &body, true); + return Api(Some(rv)); + } Api(Some(live_end(&req, &key, &body, false))) } diff --git a/src/router/multi_live.rs b/src/router/multi_live.rs index f61d3df..d8ca875 100644 --- a/src/router/multi_live.rs +++ b/src/router/multi_live.rs @@ -62,7 +62,7 @@ fn const_value(id: &str, default: i64) -> i64 { .unwrap_or(default) } -fn boost_lp(live_boost: i64) -> i64 { +pub fn boost_lp(live_boost: i64) -> i64 { databases::LIVE_BOOST[live_boost.to_string()]["lp"] .as_i64() .unwrap_or(10 * live_boost) diff --git a/src/router/tools/guest.rs b/src/router/tools/guest.rs index 020ba64..c7da220 100644 --- a/src/router/tools/guest.rs +++ b/src/router/tools/guest.rs @@ -119,6 +119,37 @@ fn proxy_card_id(id: i64) -> i64 { *rv } +// A card row for a slot the account can't actually supply: level 1, unevolved. +// Only ever handed to viewers, never written back to the account +fn stand_in_card(master_card_id: i64) -> JsonValue { + object!{ + id: master_card_id, + master_card_id: master_card_id, + exp: 0, + skill_exp: 0, + evolve: [] + } +} + +// The card object for one of the four favourite/guest slots. global::get_card +// hands back an empty object when the slot is 0 (never set) or names a card the +// account no longer holds, and an empty object reaches the client as +// master_card_id 0: Shock.CardData's constructor looks that up in masterdata and +// dereferences the row, so it throws before the guest cell is ever drawn. Same +// repair userdata::remove_deleted_custom_cards makes for a dead slot - the +// account's first card, then the default for an account holding none +fn slot_card(id: i64, user: &JsonValue) -> JsonValue { + let card = global::get_card(id, user); + if !card.is_empty() { + return card; + } + let first = &user["card_list"][0]; + if !first.is_empty() { + return first.clone(); + } + stand_in_card(DEFAULT_CARD) +} + pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) { for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] { let id = user["user"][key].as_i64().unwrap_or(0); @@ -176,12 +207,26 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) -> let mut rv = object!{ user: user["user"].clone(), - favorite_card: global::get_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user), - guest_smile_card: global::get_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user), - guest_cool_card: global::get_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user), - guest_pure_card: global::get_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user) + favorite_card: slot_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user), + guest_smile_card: slot_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user), + guest_cool_card: slot_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user), + guest_pure_card: slot_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user) }; + // The id fields have to name the same card as the objects: surfaces that + // resolve the id instead of the object (profile, friend detail) would + // otherwise look up the slot this just stood in for. A no-op for an account + // whose slots are all set + for (key, card) in [ + ("favorite_master_card_id", "favorite_card"), + ("guest_smile_master_card_id", "guest_smile_card"), + ("guest_cool_master_card_id", "guest_cool_card"), + ("guest_pure_master_card_id", "guest_pure_card") + ] { + let id = rv[card]["master_card_id"].clone(); + rv["user"][key] = id; + } + if let UserView::Detail | UserView::Ranking = view { rv["main_deck_detail"] = object!{ total_power: 0, @@ -237,6 +282,33 @@ mod tests { } } + // A slot the account can't supply still hands the viewer a resolvable card: + // an empty card object reaches the client as master_card_id 0, and + // Shock.CardData throws on an id that isn't in masterdata + #[test] + fn empty_slots_stand_in_for_a_real_card() { + let user = jzon::object!{ + "user": { "favorite_master_card_id": 0 }, + "card_list": [ + { "id": 40030007, "master_card_id": 40030007, "exp": 0, "skill_exp": 0, "evolve": [] }, + { "id": 10010001, "master_card_id": 10010001, "exp": 0, "skill_exp": 0, "evolve": [] } + ] + }; + assert_eq!(slot_card(40030007, &user)["master_card_id"].as_i64(), Some(40030007)); + // Never set, and a card the account no longer holds, both fall back to + // its first card + assert_eq!(slot_card(0, &user)["master_card_id"].as_i64(), Some(40030007)); + assert_eq!(slot_card(20010001, &user)["master_card_id"].as_i64(), Some(40030007)); + + // A tutorial-stage account holds nothing at all + let empty = jzon::object!{ "user": {}, "card_list": [] }; + let card = slot_card(0, &empty); + assert_eq!(card["master_card_id"].as_i64(), Some(DEFAULT_CARD)); + assert_eq!(card["id"].as_i64(), Some(DEFAULT_CARD)); + assert!(card["evolve"].is_array()); + assert!(!databases::CARD_LIST[DEFAULT_CARD.to_string()].is_empty()); + } + // The imported band proxies to its own character's base card, not to a // single default (the old prefix arithmetic collapsed 14000+ to Honoka) #[test] diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index 40db578..85ca549 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -1,4 +1,5 @@ pub mod user; +pub mod starter; use rusqlite::params; use lazy_static::lazy_static; @@ -787,6 +788,37 @@ pub fn export_user(token: &str) -> Option { }) } +// Every row an account owns, gone. Factored out of purge_accounts so the arcade +// sweeper (a machine that aged out takes its two accounts with it) and the +// card-rebind orphan cleanup delete exactly the same set of rows - an account +// half-deleted here is one the login path would resurrect empty. +// +// This list is also what the arcade guest reset mirrors: starter::write_starter_rows +// rewrites the eleven data rows, re-draws the token and deletes the rest, so a +// row added here has to be accounted for there too or a guest starts carrying +// the previous player's state across a credit. +pub const ACCOUNT_TABLES: &[&str] = &[ + "userdata", "userhome", "missions", "loginbonus", "sifcards", "friends", + "chats", "exchange", "event", "eventloginbonus", "server_data", "webui", + "tokens", "migration" +]; + +pub fn delete_account(user_id: i64) { + crate::database::gree::delete_uuid(user_id); + for table in ACCOUNT_TABLES { + DATABASE.lock_and_exec(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(user_id)); + } +} + +// True when the account has ever registered a data-transfer password, which is +// the only way an account can be taken over from another device. The arcade uses +// it to tell a throwaway account it made itself from a real player's account. +pub fn has_transfer_password(user_id: i64) -> bool { + !DATABASE.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(user_id)) + .unwrap_or_default() + .is_empty() +} + pub fn purge_accounts() -> usize { // If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over. let dead_uids = DATABASE.lock_and_select_all(" @@ -802,21 +834,7 @@ pub fn purge_accounts() -> usize { for uid in dead_uids.members() { let user_id = uid.as_i64().unwrap(); println!("Removing dead UID: {}", user_id); - crate::database::gree::delete_uuid(user_id); - DATABASE.lock_and_exec("DELETE FROM userdata WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM userhome WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM missions WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM loginbonus WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM sifcards WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM friends WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM chats WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM exchange WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM event WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM eventloginbonus WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM server_data WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM tokens WHERE user_id=?1", params!(user_id)); - DATABASE.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(user_id)); + delete_account(user_id); } DATABASE.lock_and_exec("VACUUM", params!()); crate::database::gree::setup(); diff --git a/src/router/userdata/starter.rs b/src/router/userdata/starter.rs new file mode 100644 index 0000000..89cce6c --- /dev/null +++ b/src/router/userdata/starter.rs @@ -0,0 +1,465 @@ +// Accounts that are born finished. +// +// Everything below Title in the client assumes a tutorial-complete account: a +// name, a favourite card, a nine-card deck, tutorial_step 130. The tutorial that +// produces one is four client requests, and nothing on the server can create +// that state on its own - which is exactly what the arcade needs, twice per +// cabinet (the machine identity and its guest) and again at every credit (the +// guest, rewritten in place). +// +// So this module replays those four requests' mutations, in their order, off +// their own handlers' code: +// +// POST /api/lottery the tutorial draw leaves a card in card_list, +// which is what /user/initialize reads as the +// account's "ur" (lottery.rs:342-358, user.rs:383) +// POST /api/user/initialize favourite + guest cards, 3000 gems, the band +// title, the nine base cards, deck slot 1, +// character_list, the first bond mission +// (user.rs:371-453) +// POST /api/user the player's name (user.rs:138-140) +// POST /api/tutorial tutorial_step 130 and full stamina +// (tutorial.rs:13-17) +// +// The one deliberate difference is the gacha: a cabinet draws nothing random. +// The chosen member's own base card takes the "ur" slot instead, which leaves +// the starter deck complete (nine distinct cards) rather than one short, and +// makes every arcade account byte-identical apart from its name. +// +// `write_starter_rows` is the single source of truth for what an account is made +// of, shared by creation and by the guest reset - the two can never drift. +// +// It is also the single source of truth for what an account is NOT made of. A +// guest is handed to a stranger every credit, so the reset has to leave behind +// exactly what a freshly created account has: the eleven data rows, one login +// token, and nothing else. Every other row `delete_account` recognises as +// account-owned (mod.rs:791) is deleted here rather than rewritten - the +// transfer code and password /api/user/registerpassword registers, the webui +// session, the gree device certificate - because each of them is a credential +// the previous player could keep and come back with. The login token is the one +// thing that is kept in the sense that the account keeps having one, but it is +// re-drawn too: the old one is on the cabinet's disk and may be on the previous +// player's phone, and /api/arcade/session hands the new one straight back to the +// cabinet in the `uuid` the client already adopts. + +use jzon::{array, object, JsonValue}; +use rusqlite::params; + +use crate::include_file; +use crate::router::{card, chat, global, items, live}; +use super::{DATABASE, NEW_USER, acc_exists, generate_uid}; + +// The member every arcade account is built around: Kousaka Honoka, the first +// member of the first band, so the deck is deterministic and recognisable. +// user/initialize derives everything else from this one id. +const STARTER_CHARACTER_ID: i64 = 1001; + +// The nine cards /user/initialize rewards for a mu's pick (user.rs:398) +const STARTER_CARDS: &[i64] = &[ + 10010001, 10020001, 10030001, 10040001, 10050001, + 10060001, 10070001, 10080001, 10090001 +]; + +// The card the tutorial gacha would have left in card_list[0]. See the module +// note: the chosen member's own base card, so the deck comes out whole. +const STARTER_UR: i64 = 10010001; + +// user.rs:396-411: 3000000 + the band offset (0 for mu's) + the member's index +// within the band, which is the last two digits of the character id +const STARTER_TITLE_ID: i64 = 3_000_000 + STARTER_CHARACTER_ID % 100; + +// Every account name is stored verbatim by /api/user, so a cabinet name is no +// more dangerous than a player name - but it arrives from a machine that types +// it once and never again, so it is clamped rather than trusted to be sane. +pub const MAX_NAME_LEN: usize = 32; + +pub fn clean_name(name: &str, fallback: &str) -> String { + let name: String = name + .chars() + .filter(|c| !c.is_control()) + .take(MAX_NAME_LEN) + .collect(); + let name = name.trim(); + if name.is_empty() { + return fallback.to_string(); + } + name.to_string() +} + +// The userdata blob a finished tutorial leaves behind, plus the two side rows it +// writes on the way (the mission progress and the chat it unlocks). +fn tutorial_complete(uid: i64, name: &str) -> (JsonValue, JsonValue, JsonValue, JsonValue) { + let now = global::timestamp(); + + // create_acc (mod.rs:230-232) + let mut user = NEW_USER.clone(); + user["user"]["id"] = uid.into(); + user["stamina"]["last_updated_time"] = now.into(); + + let mut home = jzon::parse(&include_file!("src/router/userdata/new_user_home.json")).unwrap(); + let mut missions = jzon::parse(&include_file!("src/router/userdata/missions.json")).unwrap(); + let mut chats = array![]; + + // --- POST /api/user/initialize (user.rs:371-453) ------------------------ + chat::add_chat(STARTER_CHARACTER_ID, 1, &mut chats); + + user["user"]["favorite_master_card_id"] = STARTER_UR.into(); + user["user"]["guest_smile_master_card_id"] = STARTER_UR.into(); + user["user"]["guest_cool_master_card_id"] = STARTER_UR.into(); + user["user"]["guest_pure_master_card_id"] = STARTER_UR.into(); + home["home"]["preset_setting"][0]["illust_master_card_id"] = STARTER_UR.into(); + user["gem"]["free"] = (3000).into(); + user["gem"]["total"] = (3000).into(); + user["user"]["master_title_ids"][0] = STARTER_TITLE_ID.into(); + + // The clear-mission and chat out-parameters are thrown away here exactly as + // /user/initialize throws them away (user.rs:418): the only chat a fresh + // account keeps is the one added above. + for id in STARTER_CARDS { + items::give_character(*id, &mut user, &mut missions, &mut array![], &mut array![]); + } + + let mut others = array![]; + for id in STARTER_CARDS { + if id / 10000 != STARTER_CHARACTER_ID { + others.push(*id).unwrap(); + } + } + for slot in 0..9 { + let card_id = if slot == 4 { + STARTER_UR + } else if slot < 4 { + others[slot].as_i64().unwrap_or(0) + } else { + others[slot - 1].as_i64().unwrap_or(0) + }; + user["deck_list"][0]["main_card_ids"][slot] = card_id.into(); + } + + user["character_list"] = array![object!{ + master_character_id: STARTER_CHARACTER_ID, + exp: 1 + }]; + let bond = live::bond_missions(STARTER_CHARACTER_ID); + if !bond.is_empty() { + items::advance_mission(bond[0][1].as_i64().unwrap(), 1, bond[0][0].as_i64().unwrap(), &mut missions); + } + + // --- POST /api/user (user.rs:138-140) ----------------------------------- + user["user"]["name"] = name.into(); + + // --- POST /api/tutorial, the final step (tutorial.rs:13-17) ------------- + user["tutorial_step"] = (130).into(); + user["stamina"]["stamina"] = (100).into(); + user["stamina"]["last_updated_time"] = now.into(); + + (user, home, missions, chats) +} + +// Everything create_acc seeds, as (table, value). The column always carries the +// table's own name; `userdata` is the one row with extra columns and is written +// by the caller. +fn side_rows(chats: &JsonValue, home: &JsonValue, missions: &JsonValue) -> Vec<(&'static str, String)> { + let now = global::timestamp(); + vec![ + ("userhome", jzon::stringify(home.clone())), + ("missions", jzon::stringify(missions.clone())), + ("chats", jzon::stringify(chats.clone())), + ("loginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)), + ("eventloginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)), + ("sifcards", String::from("[]")), + ("friends", String::from(r#"{"friend_user_id_list":[],"request_user_id_list":[],"pending_user_id_list":[]}"#)), + ("event", String::from("{}")), + ("exchange", String::from("[]")), + ("server_data", format!(r#"{{"server_time_set":{},"server_time":1709272800}}"#, now)) + ] +} + +// The rows delete_account (mod.rs:791) removes that write_starter_rows does not +// write back: everything an account owns that is a credential rather than +// progress. `tokens` is not here because the starter write issues a fresh one in +// the same transaction, and `userdata` and its ten side tables are not here +// because they are rewritten. Anything added to delete_account's list belongs in +// one of those three places or the guest reset starts leaking again. +const CARRIED_CREDENTIAL_TABLES: &[&str] = &["migration", "webui"]; + +// Writes the whole account inside one transaction: the eleven data rows back to +// the starter state, every carried-over credential gone, and a login token +// nobody has seen before. Upserts rather than inserts, so the same code both +// creates an account and rewrites an existing one - the reset can never +// half-apply, and it cannot miss a table that creation seeds because there is +// only one list. Returns the account's new login token. +fn write_starter_rows(uid: i64, name: &str) -> Result { + let (user, home, missions, chats) = tutorial_complete(uid, name); + let rows = side_rows(&chats, &home, &missions); + let friend_request_disabled = user["user"]["friend_request_disabled"].as_i32().unwrap_or(1); + let protocol_version = if card::account_has_custom_cards(&user) { card::PROTOCOL_VERSION } else { 0 }; + let userdata = jzon::stringify(user); + let token = global::create_token(); + + DATABASE.lock_and_transact(|conn| { + conn.execute( + "INSERT INTO userdata (user_id, userdata, friend_request_disabled, protocol_version) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(user_id) DO UPDATE SET userdata=?2, friend_request_disabled=?3, protocol_version=?4", + params!(uid, &userdata, friend_request_disabled, protocol_version) + )?; + for (table, value) in &rows { + conn.execute( + &format!( + "INSERT INTO {0} (user_id, {0}) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET {0}=?2", + table + ), + params!(uid, value) + )?; + } + for table in CARRIED_CREDENTIAL_TABLES { + conn.execute(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(uid))?; + } + // The account's one login token, re-drawn. The DELETE by token is + // create_acc's own collision guard (mod.rs:238); the DELETE by user id is + // what makes the INSERT a rotation rather than a primary-key conflict. + conn.execute("DELETE FROM tokens WHERE token=?1", params!(&token))?; + conn.execute("DELETE FROM tokens WHERE user_id=?1", params!(uid))?; + conn.execute("INSERT INTO tokens (user_id, token) VALUES (?1, ?2)", params!(uid, &token))?; + Ok(token) + }) +} + +// A brand new account, already through the tutorial. Returns its user id and its +// login token - the uuid the client stores and authenticates with from then on. +pub fn create(name: &str) -> Option<(i64, String)> { + let uid = generate_uid(); + match write_starter_rows(uid, name) { + Ok(token) => Some((uid, token)), + Err(err) => { + println!("arcade: could not create account {}: {}", uid, err); + None + } + } +} + +// Rewrites an existing account back to the starter state, keeping its user id: +// the cabinet's guest, at the start of every credit. Returns the account's new +// login token, which /api/arcade/session answers with - the previous player's +// copy of the old one stops working the moment their credit ends. +pub fn reset(uid: i64, name: &str) -> Option { + if !acc_exists(uid) { + return None; + } + let token = match write_starter_rows(uid, name) { + Ok(token) => token, + Err(err) => { + println!("arcade: could not reset account {}: {}", uid, err); + return None; + } + }; + // The gree device certificate is the one account-owned credential that lives + // in another database, so it cannot ride the transaction above - but it is on + // delete_account's list for the same reason the two tables are, and a stale + // one would still name this account (database/gree.rs:98). + crate::database::gree::delete_uuid(uid); + Some(token) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::userdata; + + fn stored(uid: i64) -> JsonValue { + jzon::parse(&DATABASE.lock_and_select("SELECT userdata FROM userdata WHERE user_id=?1", params!(uid)).unwrap()).unwrap() + } + + fn row(uid: i64, table: &str) -> String { + DATABASE.lock_and_select(&format!("SELECT {0} FROM {0} WHERE user_id=?1", table), params!(uid)).unwrap() + } + + // A created account is already past the tutorial: named, nine cards, a full + // deck with the favourite in the centre, gems, title, bond and full stamina + #[test] + fn a_created_account_is_tutorial_complete() { + let _lock = crate::runtime::lock_test_data_path(); + + let (uid, token) = create("Cabinet 1").unwrap(); + assert_eq!(userdata::uid_from_login_token(&token), uid); + + let user = stored(uid); + assert_eq!(user["user"]["id"].as_i64(), Some(uid)); + assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1")); + // 130 is what every gate downstream tests for (live.rs:92, :385, :435) + assert_eq!(user["tutorial_step"].as_i64(), Some(130)); + assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100)); + assert_eq!(user["gem"]["free"].as_i64(), Some(3000)); + assert_eq!(user["gem"]["total"].as_i64(), Some(3000)); + assert_eq!(user["user"]["master_title_ids"][0].as_i64(), Some(3000001)); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(STARTER_UR)); + assert_eq!(user["character_list"][0]["master_character_id"].as_i64(), Some(STARTER_CHARACTER_ID)); + + assert_eq!(user["card_list"].len(), STARTER_CARDS.len()); + for id in STARTER_CARDS { + assert!(user["card_list"].members().any(|c| c["master_card_id"] == *id), "missing {}", id); + } + + // The deck is whole: nine distinct cards, the favourite in the centre + let deck = &user["deck_list"][0]["main_card_ids"]; + assert_eq!(deck.len(), 9); + assert_eq!(deck[4].as_i64(), Some(STARTER_UR)); + let mut seen = Vec::new(); + for slot in deck.members() { + let id = slot.as_i64().unwrap(); + assert!(id != 0, "empty deck slot"); + assert!(!seen.contains(&id), "duplicate card {} in the deck", id); + seen.push(id); + } + + // The home row carries the favourite too, and every side row exists + assert_eq!(jzon::parse(&row(uid, "userhome")).unwrap()["home"]["preset_setting"][0]["illust_master_card_id"].as_i64(), Some(STARTER_UR)); + assert_eq!(row(uid, "sifcards"), "[]"); + assert_eq!(row(uid, "exchange"), "[]"); + assert_eq!(row(uid, "event"), "{}"); + assert!(!jzon::parse(&row(uid, "missions")).unwrap().is_empty()); + assert!(!jzon::parse(&row(uid, "chats")).unwrap().is_empty()); + + // The account is NOT one of the dead ones the purge sweeper collects + // (mod.rs:792-801): it has cards, a real name and step 130 + assert!(!user["card_list"].is_empty()); + assert_ne!(user["user"]["name"].as_str(), Some("Tutorial in progress")); + } + + // The guest reset keeps the identity and the token and throws away + // everything the last player did with it + #[test] + fn a_reset_keeps_the_identity_and_wipes_the_progress() { + let _lock = crate::runtime::lock_test_data_path(); + + let (uid, token) = create("GUEST").unwrap(); + + // A credit's worth of play, on every row a reset has to reach + let mut user = stored(uid); + user["user"]["name"] = "Somebody".into(); + user["user"]["exp"] = (12345).into(); + user["stamina"]["stamina"] = (3).into(); + user["gem"]["free"] = (99999).into(); + user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 7, high_score: 654321, max_combo: 300 }).unwrap(); + user["live_mission_list"].push(object!{ master_live_id: 1100101, clear_master_live_mission_ids: [1, 2] }).unwrap(); + user["item_list"].push(object!{ id: 17001001, master_item_id: 17001001, amount: 50 }).unwrap(); + user["card_list"].push(object!{ id: 10010013, master_card_id: 10010013, exp: 0, skill_exp: 0, evolve: [], created_date_time: 0 }).unwrap(); + userdata::save_acc(&token, user); + userdata::save_acc_friends(&token, object!{ + friend_user_id_list: [1], + request_user_id_list: [], + pending_user_id_list: [] + }); + userdata::save_server_data(&token, object!{ last_live_started: [object!{ master_live_id: 1100101 }] }); + userdata::save_acc_exchange(&token, array![1, 2, 3]); + + let new_token = reset(uid, "Cabinet 1").expect("the reset was refused"); + + // Same account, a new login token - the cabinet is handed the new one in + // the /api/arcade/session answer, and the previous player's copy of the + // old one now names nothing + assert_ne!(new_token, token, "the guest's login token was reused"); + assert_eq!(userdata::uid_from_login_token(&new_token), uid); + assert_eq!(userdata::uid_from_login_token(&token), 0, "the previous credit's token still logs in"); + + let user = stored(uid); + assert_eq!(user["user"]["id"].as_i64(), Some(uid)); + assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1")); + assert_eq!(user["user"]["exp"].as_i64(), Some(0)); + assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100)); + assert_eq!(user["gem"]["free"].as_i64(), Some(3000)); + assert_eq!(user["live_list"].len(), 0); + assert_eq!(user["live_mission_list"].len(), 0); + assert_eq!(user["item_list"].len(), 0); + assert_eq!(user["card_list"].len(), STARTER_CARDS.len()); + assert_eq!(user["deck_list"][0]["main_card_ids"][4].as_i64(), Some(STARTER_UR)); + + // Every side row went back too + assert_eq!(jzon::parse(&row(uid, "friends")).unwrap()["friend_user_id_list"].len(), 0); + assert!(jzon::parse(&row(uid, "server_data")).unwrap()["last_live_started"].is_null()); + assert_eq!(row(uid, "exchange"), "[]"); + } + + // Resetting something that is not an account is refused rather than + // conjuring one out of nothing + #[test] + fn resetting_an_unknown_account_is_refused() { + let _lock = crate::runtime::lock_test_data_path(); + assert!(reset(123, "Cabinet 1").is_none()); + } + + // A guest is handed to a stranger every credit, so a reset has to take every + // credential the last player could have left on it - not just the progress. + // The list is delete_account's (mod.rs:791) minus the rows the starter write + // rewrites and the token it re-draws. + #[test] + fn a_reset_takes_every_credential_the_last_player_left() { + let _lock = crate::runtime::lock_test_data_path(); + + let (uid, token) = create("GUEST").unwrap(); + + // The previous player, on the cabinet during their credit: a transfer + // code and password (/api/user/registerpassword), a webui session logged + // in with them, and a gree device certificate. + let code = userdata::user::migration::save_acc_transfer(uid, "hunter2"); + assert!(userdata::has_transfer_password(uid)); + let webui_token = userdata::webui_login(uid, "hunter2").expect("the webui login was refused"); + assert_eq!(userdata::webui_login_token(&webui_token).as_deref(), Some(token.as_str())); + crate::database::gree::update_cert(uid, "a device certificate"); + assert!(crate::database::gree::get_user_cert(&token).is_some()); + + let new_token = reset(uid, "Cabinet 1").expect("the reset was refused"); + + // The transfer code and password are gone: neither the game's own + // takeover check nor the webui login answers to them any more + assert!(!userdata::has_transfer_password(uid), "the transfer password survived the reset"); + assert!(!userdata::user::migration::get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap_or(false), + "the previous player's transfer code still takes the account over"); + assert!(userdata::webui_login(uid, "hunter2").is_err(), "the previous player can still log the webui in"); + + // The webui session they left open is gone too + assert!(userdata::webui_login_token(&webui_token).is_none(), "the previous player's webui session survived the reset"); + assert!(userdata::webui_get_user(&webui_token).is_none()); + + // So is the device certificate, which named the account from a phone + assert!(crate::database::gree::get_user_cert(&token).is_none(), "the gree certificate survived the reset"); + assert!(crate::database::gree::get_user_cert(&new_token).is_none()); + + // And the account is still perfectly usable under its new token + assert_eq!(userdata::uid_from_login_token(&new_token), uid); + assert_eq!(stored(uid)["user"]["name"].as_str(), Some("Cabinet 1")); + } + + // Every table delete_account clears is either rewritten by the starter write, + // re-drawn (the token) or deleted by it. A row added to delete_account without + // a decision here is a leak across credits, so the lists are compared rather + // than trusted to have been kept in step. + #[test] + fn the_reset_accounts_for_every_table_a_deletion_clears() { + let rewritten = ["userdata", "userhome", "missions", "chats", "loginbonus", + "eventloginbonus", "sifcards", "friends", "event", "exchange", "server_data"]; + for table in userdata::ACCOUNT_TABLES { + assert!( + rewritten.contains(table) || CARRIED_CREDENTIAL_TABLES.contains(table) || *table == "tokens", + "delete_account clears {} and the guest reset does nothing about it", + table + ); + } + // And the side-row list really is what the reset rewrites + let rows = side_rows(&array![], &object!{}, &object!{}); + for (table, _) in &rows { + assert!(rewritten.contains(table), "{} is written by the reset but not listed above", table); + } + assert_eq!(rows.len() + 1, rewritten.len()); + } + + // Cabinet names arrive from an operator typing into a machine + #[test] + fn names_are_clamped_not_trusted() { + assert_eq!(clean_name(" Cabinet 1 ", "ARCADE"), "Cabinet 1"); + assert_eq!(clean_name("", "ARCADE"), "ARCADE"); + assert_eq!(clean_name(" ", "ARCADE"), "ARCADE"); + assert_eq!(clean_name("a\nb\tc", "ARCADE"), "abc"); + assert_eq!(clean_name(&"x".repeat(200), "ARCADE").len(), MAX_NAME_LEN); + } +} diff --git a/src/router/webui.rs b/src/router/webui.rs index 6aa163b..6365017 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -230,6 +230,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse { custom_songs: !crate::router::custom_song::disabled(), custom_cards: !crate::router::custom_card::disabled(), custom_3dmv: !crate::router::custom_3dmv::disabled(), + arcade: !crate::router::arcade::disabled(), links: { global: args.global_android, japan: args.japan_android, @@ -483,7 +484,11 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse { can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT), can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) || permissions::has(uid, permissions::PERMISSION_REVOKE), - can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) + can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE), + // Cabinets are server hardware, not user content: there is no + // finer-grained arcade scope, so managing them takes the top-level + // one that every --owner uid holds implicitly + can_manage_arcade: !crate::router::arcade::disabled() && permissions::has(uid, permissions::ALL) } }; HttpResponse::Ok() @@ -557,6 +562,84 @@ pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse { .body(jzon::stringify(resp)) } +// The operator's cabinet list. Owner scope only: a machine row names the two +// accounts a cabinet owns, and removing one deletes them. +pub fn list_arcade_machines(req: HttpRequest) -> HttpResponse { + if crate::router::arcade::disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + if !permissions::has(uid, permissions::ALL) { + return error("You do not have permission to manage arcade machines"); + } + let resp = object!{ + result: "OK", + data: { + machines: crate::router::arcade::webui_machines() + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse { + if crate::router::arcade::disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + if !permissions::has(uid, permissions::ALL) { + return error("You do not have permission to manage arcade machines"); + } + let body = jzon::parse(&body).unwrap_or(object!{}); + if let Err(e) = crate::router::arcade::webui_remove_machine(body["machine_id"].as_str().unwrap_or("")) { + return error(&e); + } + let resp = object!{ + result: "OK" + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The account page's "bind arcade card" form. The cabinet's own endpoint speaks +// the encrypted game protocol behind the asset gate, which a browser cannot, so +// this is the browser's door onto the same rule - arcade::bind_card, not a copy +// of it. A webui session is required on top of the transfer code and password: +// the page is behind login anyway, and the extra proof costs nothing. +pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { + if crate::router::arcade::disabled() { + return HttpResponse::NotFound().finish(); + } + if session_uid(&req).is_none() { + return error("Not logged in"); + } + let body = jzon::parse(&body).unwrap_or(object!{}); + match crate::router::arcade::bind_card( + body["card_id"].as_str().unwrap_or(""), + body["migrationCode"].as_str().unwrap_or(""), + body["pass"].as_str().unwrap_or("") + ) { + Ok(user_id) => { + let resp = object!{ + result: "OK", + data: { + user_id: user_id + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) + }, + Err(reason) => error(&reason) + } +} + pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { let token = get_login_token(&req); if token.is_none() {