wip stuff (idk)

This commit is contained in:
Ethan O'Brien
2026-08-26 08:21:29 -05:00
parent 1d12a9415c
commit 9e5228e9f5
13 changed files with 2116 additions and 22 deletions

View File

@@ -16,6 +16,7 @@ args=(
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv) [ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
add_opt() { add_opt() {
local value="$1" flag="$2" 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) # Server owner uid(s) for the permission system (comma-separated)
add_opt "${OWNER:-}" --owner 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. # Asset / image paths.
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
add_opt "${MASTERDATA:-}" --masterdata add_opt "${MASTERDATA:-}" --masterdata

View File

@@ -4,3 +4,4 @@ pub mod custom_card;
pub mod custom_3dmv; pub mod custom_3dmv;
pub mod permissions; pub mod permissions;
pub mod announcements; pub mod announcements;
pub mod arcade;

474
src/database/arcade.rs Normal file
View File

@@ -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<JsonValue> {
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::<usize, String>(0)?,
name: row.get::<usize, String>(1)?,
machine_user_id: row.get::<usize, i64>(2)?,
guest_user_id: row.get::<usize, i64>(3)?,
created: row.get::<usize, i64>(4)?,
last_seen: row.get::<usize, i64>(5)?
})
}).ok()
}
pub fn get_machine(machine_id: &str) -> Option<JsonValue> {
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<JsonValue> {
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::<usize, String>(0)?,
name: row.get::<usize, String>(1)?,
machine_user_id: row.get::<usize, i64>(2)?,
guest_user_id: row.get::<usize, i64>(3)?,
created: row.get::<usize, i64>(4)?,
last_seen: row.get::<usize, i64>(5)?,
play_count: row.get::<usize, i64>(6)?,
cleared_count: row.get::<usize, i64>(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::<usize, String>(0)?,
machine_user_id: row.get::<usize, i64>(1)?,
guest_user_id: row.get::<usize, i64>(2)?,
last_seen: row.get::<usize, i64>(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<i64> {
DATABASE.lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
}
// Point a card at an account. `created` survives a re-bind: the card is the same
// physical object, only the account behind it changed. The cabinet record is
// cleared, because the previous holder's sessions say nothing about this one -
// and so is the LP-free window, which was bought for the previous account
pub fn set_card(card_id: &str, user_id: i64) {
DATABASE.lock_and_exec(
"INSERT INTO cards (card_id, user_id, created, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, '', 0, 0)
ON CONFLICT(card_id) DO UPDATE SET user_id=?2, last_machine_id='', last_session=0, session_until=0",
params!(card_id, user_id, global::timestamp() as i64)
);
}
// The cabinet this card is playing at right now and how long the credit it just
// 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::<usize, String>(0)?, row.get::<usize, i64>(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<?1",
params!(until, card_id)
);
}
// Only the session tests need to age a card's window; production code only ever
// opens one at a session or pushes it forward through extend_card_session
#[cfg(test)]
pub fn backdate_card_session_for_test(card_id: &str, last_session: i64, session_until: i64) {
DATABASE.lock_and_exec(
"UPDATE cards SET last_session=?1, session_until=?2 WHERE card_id=?3",
params!(last_session, session_until, card_id)
);
}
// The machine that most recently ran a session for this account through any of
// its cards. Empty when the account has no card, or has never been at a cabinet
pub fn last_machine_of_card_account(user_id: i64) -> Option<String> {
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::<usize, i64>(0)?,
live_id: row.get::<usize, i64>(1)?,
level: row.get::<usize, i64>(2)?,
score: row.get::<usize, i64>(3)?,
rank: row.get::<usize, i64>(4)?,
at: row.get::<usize, i64>(5)?,
cleared: row.get::<usize, i64>(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);
}
}

View File

@@ -31,6 +31,12 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
if args.purge { if args.purge {
println!("Purging accounts..."); println!("Purging accounts...");
// Cabinets first, so the accounts they take with them are gone before
// purge_accounts runs its VACUUM over the same database
let machines = crate::router::arcade::purge_machines();
if machines > 0 {
println!("Purged {} arcade machine(s)", machines);
}
let ct = crate::router::userdata::purge_accounts(); let ct = crate::router::userdata::purge_accounts();
println!("Purged {} accounts", ct); println!("Purged {} accounts", ct);
} }

View File

@@ -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")] #[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
pub enable_custom_3dmv: bool, pub enable_custom_3dmv: bool,
#[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")] #[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<i64>, pub owner: Vec<i64>,

View File

@@ -1,4 +1,5 @@
pub mod start; pub mod start;
pub mod arcade;
pub mod global; pub mod global;
pub mod login; pub mod login;
pub mod userdata; 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/cheat" => webui::cheat(req, body),
"/api/webui/grantPermission" => webui::grant_permission(req, body), "/api/webui/grantPermission" => webui::grant_permission(req, body),
"/api/webui/revokePermission" => webui::revoke_permission(req, body), "/api/webui/revokePermission" => webui::revoke_permission(req, body),
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} else { } else {
@@ -242,6 +245,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/customCardLimits" => webui::custom_card_limits(req), "/api/webui/customCardLimits" => webui::custom_card_limits(req),
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
"/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/myScopes" => webui::my_scopes(req),
"/api/webui/listArcadeMachines" => webui::list_arcade_machines(req),
_ => api_req(req, body).await _ => 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::get().to(home::gift_get))
.route(actix_web::web::post().to(user::gift)) .route(actix_web::web::post().to(user::gift))
) )
.configure(arcade::routes)
.configure(card::routes) .configure(card::routes)
.configure(chat::routes) .configure(chat::routes)
.configure(custom_song::routes) .configure(custom_song::routes)

928
src/router/arcade.rs Normal file
View File

@@ -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<String> {
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<i64, String> {
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<i64> {
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<i64> {
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<i64> {
let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?;
let Some(started) = live::get_started_live(login_token, body) else {
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<String> {
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<i64> {
// The start record is read before the module's on/off switch so an ordinary
// player's retire - whose start was never flagged - costs one lookup and
// nothing else, exactly as it did before the ledger learned about failures.
let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?;
if disabled() {
return None;
}
Some(user_id)
}
fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> {
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);
}
}
}

View File

@@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder};
use rand::RngExt; use rand::RngExt;
use lazy_static::lazy_static; 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::clear_rate::live_completed;
use crate::router::tools::guest; 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 { 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); 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 { 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 // Body-derived, so defaulted rather than unwrapped: a retire is not worth a
// panicked worker either (live_completed ignores an unknown id/level). // 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 { 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); start_live(&key, &body);
Api(Some(array![])) 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 { 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))) Api(Some(live_end(&req, &key, &body, false)))
} }

View File

@@ -62,7 +62,7 @@ fn const_value(id: &str, default: i64) -> i64 {
.unwrap_or(default) .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"] databases::LIVE_BOOST[live_boost.to_string()]["lp"]
.as_i64() .as_i64()
.unwrap_or(10 * live_boost) .unwrap_or(10 * live_boost)

View File

@@ -119,6 +119,37 @@ fn proxy_card_id(id: i64) -> i64 {
*rv *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) { 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"] { 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); 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!{ let mut rv = object!{
user: user["user"].clone(), user: user["user"].clone(),
favorite_card: global::get_card(user["user"]["favorite_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: global::get_card(user["user"]["guest_smile_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: global::get_card(user["user"]["guest_cool_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: global::get_card(user["user"]["guest_pure_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 { if let UserView::Detail | UserView::Ranking = view {
rv["main_deck_detail"] = object!{ rv["main_deck_detail"] = object!{
total_power: 0, 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 // The imported band proxies to its own character's base card, not to a
// single default (the old prefix arithmetic collapsed 14000+ to Honoka) // single default (the old prefix arithmetic collapsed 14000+ to Honoka)
#[test] #[test]

View File

@@ -1,4 +1,5 @@
pub mod user; pub mod user;
pub mod starter;
use rusqlite::params; use rusqlite::params;
use lazy_static::lazy_static; use lazy_static::lazy_static;
@@ -787,6 +788,37 @@ pub fn export_user(token: &str) -> Option<JsonValue> {
}) })
} }
// 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 { 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. // 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(" let dead_uids = DATABASE.lock_and_select_all("
@@ -802,21 +834,7 @@ pub fn purge_accounts() -> usize {
for uid in dead_uids.members() { for uid in dead_uids.members() {
let user_id = uid.as_i64().unwrap(); let user_id = uid.as_i64().unwrap();
println!("Removing dead UID: {}", user_id); println!("Removing dead UID: {}", user_id);
crate::database::gree::delete_uuid(user_id); delete_account(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));
} }
DATABASE.lock_and_exec("VACUUM", params!()); DATABASE.lock_and_exec("VACUUM", params!());
crate::database::gree::setup(); crate::database::gree::setup();

View File

@@ -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<String, rusqlite::Error> {
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<String> {
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);
}
}

View File

@@ -230,6 +230,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
custom_songs: !crate::router::custom_song::disabled(), custom_songs: !crate::router::custom_song::disabled(),
custom_cards: !crate::router::custom_card::disabled(), custom_cards: !crate::router::custom_card::disabled(),
custom_3dmv: !crate::router::custom_3dmv::disabled(), custom_3dmv: !crate::router::custom_3dmv::disabled(),
arcade: !crate::router::arcade::disabled(),
links: { links: {
global: args.global_android, global: args.global_android,
japan: args.japan_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_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|| permissions::has(uid, permissions::PERMISSION_REVOKE), || 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() HttpResponse::Ok()
@@ -557,6 +562,84 @@ pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .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 { pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req); let token = get_login_token(&req);
if token.is_none() { if token.is_none() {