mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 23:12:20 +08:00
wip stuff (idk)
This commit is contained in:
@@ -16,6 +16,7 @@ args=(
|
||||
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
||||
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
|
||||
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
|
||||
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
|
||||
|
||||
add_opt() {
|
||||
local value="$1" flag="$2"
|
||||
@@ -35,6 +36,12 @@ add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
|
||||
# Server owner uid(s) for the permission system (comma-separated)
|
||||
add_opt "${OWNER:-}" --owner
|
||||
|
||||
# Days an unseen arcade machine survives --purge
|
||||
add_opt "${ARCADE_MACHINE_TTL:-}" --arcade-machine-ttl
|
||||
|
||||
# Minutes a card's credit buys LP-free play for after /api/arcade/session
|
||||
add_opt "${ARCADE_SESSION_TTL:-}" --arcade-session-ttl
|
||||
|
||||
# Asset / image paths.
|
||||
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
|
||||
add_opt "${MASTERDATA:-}" --masterdata
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod custom_card;
|
||||
pub mod custom_3dmv;
|
||||
pub mod permissions;
|
||||
pub mod announcements;
|
||||
pub mod arcade;
|
||||
|
||||
474
src/database/arcade.rs
Normal file
474
src/database/arcade.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
||||
|
||||
if args.purge {
|
||||
println!("Purging accounts...");
|
||||
// Cabinets first, so the accounts they take with them are gone before
|
||||
// purge_accounts runs its VACUUM over the same database
|
||||
let machines = crate::router::arcade::purge_machines();
|
||||
if machines > 0 {
|
||||
println!("Purged {} arcade machine(s)", machines);
|
||||
}
|
||||
let ct = crate::router::userdata::purge_accounts();
|
||||
println!("Purged {} accounts", ct);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,15 @@ pub struct Args {
|
||||
#[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
|
||||
pub enable_custom_3dmv: bool,
|
||||
|
||||
#[arg(long, default_value_t = false, help = "Enable arcade mode (cabinet registration, per-machine guest accounts, LP-free arcade lives). Disabled by default; every /api/arcade endpoint and webui element is hidden unless this is set")]
|
||||
pub enable_arcade: bool,
|
||||
|
||||
#[arg(long, default_value_t = 90, help = "Days an arcade machine may go unseen before --purge deletes it together with its machine and guest accounts")]
|
||||
pub arcade_machine_ttl: u64,
|
||||
|
||||
#[arg(long, default_value_t = 30, help = "Minutes a card-bound account may play LP-free after the cabinet ran /api/arcade/session for its card. Each arcade live restarts the window, up to four windows from the session itself; outside it the account is an ordinary phone account and pays LP")]
|
||||
pub arcade_session_ttl: u64,
|
||||
|
||||
#[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")]
|
||||
pub owner: Vec<i64>,
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod start;
|
||||
pub mod arcade;
|
||||
pub mod global;
|
||||
pub mod login;
|
||||
pub mod userdata;
|
||||
@@ -223,6 +224,8 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
|
||||
"/api/webui/cheat" => webui::cheat(req, body),
|
||||
"/api/webui/grantPermission" => webui::grant_permission(req, body),
|
||||
"/api/webui/revokePermission" => webui::revoke_permission(req, body),
|
||||
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
|
||||
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body),
|
||||
_ => api_req(req, body).await
|
||||
}
|
||||
} else {
|
||||
@@ -242,6 +245,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
|
||||
"/api/webui/customCardLimits" => webui::custom_card_limits(req),
|
||||
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
|
||||
"/api/webui/myScopes" => webui::my_scopes(req),
|
||||
"/api/webui/listArcadeMachines" => webui::list_arcade_machines(req),
|
||||
_ => api_req(req, body).await
|
||||
}
|
||||
}
|
||||
@@ -261,6 +265,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.route(actix_web::web::get().to(home::gift_get))
|
||||
.route(actix_web::web::post().to(user::gift))
|
||||
)
|
||||
.configure(arcade::routes)
|
||||
.configure(card::routes)
|
||||
.configure(chat::routes)
|
||||
.configure(custom_song::routes)
|
||||
|
||||
928
src/router/arcade.rs
Normal file
928
src/router/arcade.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder};
|
||||
use rand::RngExt;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use crate::router::{databases, global, items, userdata, Login, Session, Api};
|
||||
use crate::router::{arcade, databases, global, items, userdata, Login, Session, Api};
|
||||
use crate::router::clear_rate::live_completed;
|
||||
use crate::router::tools::guest;
|
||||
|
||||
@@ -25,7 +25,17 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
|
||||
|
||||
async fn retire(Session { key, body }: Session) -> impl Responder {
|
||||
// An arcade song whose life gauge emptied is played out and reported here,
|
||||
// not at /live/end: there is no cleared flag on the end wire and live_end_ex
|
||||
// records a clear unconditionally. So this is the only place the cabinet's
|
||||
// ledger can learn about a failed song, and it has to look before live_retire
|
||||
// sweeps the start record that proves the song was a cabinet's. Nothing about
|
||||
// an ordinary retire changes: an unflagged start answers None here.
|
||||
let arcade_user = arcade::arcade_retire_user(&key, &body);
|
||||
live_retire(&key, &body);
|
||||
if let Some(user_id) = arcade_user {
|
||||
arcade::record_play(user_id, &body, false);
|
||||
}
|
||||
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 {
|
||||
// Body-derived, so defaulted rather than unwrapped: a retire is not worth a
|
||||
// panicked worker either (live_completed ignores an unknown id/level).
|
||||
@@ -345,6 +355,10 @@ pub fn start_live(login_token: &str, body: &JsonValue) {
|
||||
}
|
||||
|
||||
async fn start(Session { key, body }: Session) -> impl Responder {
|
||||
// A live starting on an arcade cabinet is a sighting for that cabinet: its
|
||||
// last_seen is what the TTL sweeper measures, and a machine in daily use
|
||||
// must never age out from under its own players.
|
||||
arcade::live_started(&key, &body);
|
||||
start_live(&key, &body);
|
||||
Api(Some(array![]))
|
||||
}
|
||||
@@ -840,6 +854,18 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
|
||||
}
|
||||
|
||||
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||
// "arcade": true - a credit in the cabinet already paid for this play, so LP
|
||||
// is neither read nor decremented. The seam is the one /multi_live/end uses
|
||||
// (live_end_ex's consume_lp), with use_lp injected as one normal 1x play, so
|
||||
// every reward, the EXP, the bonds, the high score and the clear all record
|
||||
// exactly as they do for a phone player. The flag is only honoured for an
|
||||
// account that really belongs to a cabinet - see arcade::arcade_play_user.
|
||||
if let Some(user_id) = arcade::arcade_play_user(&key, &body) {
|
||||
let body = arcade::live_end_body(&body);
|
||||
let rv = live_end_ex(&req, &key, &body, false, false, true);
|
||||
arcade::record_play(user_id, &body, true);
|
||||
return Api(Some(rv));
|
||||
}
|
||||
Api(Some(live_end(&req, &key, &body, false)))
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ fn const_value(id: &str, default: i64) -> i64 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn boost_lp(live_boost: i64) -> i64 {
|
||||
pub fn boost_lp(live_boost: i64) -> i64 {
|
||||
databases::LIVE_BOOST[live_boost.to_string()]["lp"]
|
||||
.as_i64()
|
||||
.unwrap_or(10 * live_boost)
|
||||
|
||||
@@ -119,6 +119,37 @@ fn proxy_card_id(id: i64) -> i64 {
|
||||
*rv
|
||||
}
|
||||
|
||||
// A card row for a slot the account can't actually supply: level 1, unevolved.
|
||||
// Only ever handed to viewers, never written back to the account
|
||||
fn stand_in_card(master_card_id: i64) -> JsonValue {
|
||||
object!{
|
||||
id: master_card_id,
|
||||
master_card_id: master_card_id,
|
||||
exp: 0,
|
||||
skill_exp: 0,
|
||||
evolve: []
|
||||
}
|
||||
}
|
||||
|
||||
// The card object for one of the four favourite/guest slots. global::get_card
|
||||
// hands back an empty object when the slot is 0 (never set) or names a card the
|
||||
// account no longer holds, and an empty object reaches the client as
|
||||
// master_card_id 0: Shock.CardData's constructor looks that up in masterdata and
|
||||
// dereferences the row, so it throws before the guest cell is ever drawn. Same
|
||||
// repair userdata::remove_deleted_custom_cards makes for a dead slot - the
|
||||
// account's first card, then the default for an account holding none
|
||||
fn slot_card(id: i64, user: &JsonValue) -> JsonValue {
|
||||
let card = global::get_card(id, user);
|
||||
if !card.is_empty() {
|
||||
return card;
|
||||
}
|
||||
let first = &user["card_list"][0];
|
||||
if !first.is_empty() {
|
||||
return first.clone();
|
||||
}
|
||||
stand_in_card(DEFAULT_CARD)
|
||||
}
|
||||
|
||||
pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) {
|
||||
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
||||
let id = user["user"][key].as_i64().unwrap_or(0);
|
||||
@@ -176,12 +207,26 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) ->
|
||||
|
||||
let mut rv = object!{
|
||||
user: user["user"].clone(),
|
||||
favorite_card: global::get_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_smile_card: global::get_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_cool_card: global::get_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_pure_card: global::get_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user)
|
||||
favorite_card: slot_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_smile_card: slot_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_cool_card: slot_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||
guest_pure_card: slot_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user)
|
||||
};
|
||||
|
||||
// The id fields have to name the same card as the objects: surfaces that
|
||||
// resolve the id instead of the object (profile, friend detail) would
|
||||
// otherwise look up the slot this just stood in for. A no-op for an account
|
||||
// whose slots are all set
|
||||
for (key, card) in [
|
||||
("favorite_master_card_id", "favorite_card"),
|
||||
("guest_smile_master_card_id", "guest_smile_card"),
|
||||
("guest_cool_master_card_id", "guest_cool_card"),
|
||||
("guest_pure_master_card_id", "guest_pure_card")
|
||||
] {
|
||||
let id = rv[card]["master_card_id"].clone();
|
||||
rv["user"][key] = id;
|
||||
}
|
||||
|
||||
if let UserView::Detail | UserView::Ranking = view {
|
||||
rv["main_deck_detail"] = object!{
|
||||
total_power: 0,
|
||||
@@ -237,6 +282,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// A slot the account can't supply still hands the viewer a resolvable card:
|
||||
// an empty card object reaches the client as master_card_id 0, and
|
||||
// Shock.CardData throws on an id that isn't in masterdata
|
||||
#[test]
|
||||
fn empty_slots_stand_in_for_a_real_card() {
|
||||
let user = jzon::object!{
|
||||
"user": { "favorite_master_card_id": 0 },
|
||||
"card_list": [
|
||||
{ "id": 40030007, "master_card_id": 40030007, "exp": 0, "skill_exp": 0, "evolve": [] },
|
||||
{ "id": 10010001, "master_card_id": 10010001, "exp": 0, "skill_exp": 0, "evolve": [] }
|
||||
]
|
||||
};
|
||||
assert_eq!(slot_card(40030007, &user)["master_card_id"].as_i64(), Some(40030007));
|
||||
// Never set, and a card the account no longer holds, both fall back to
|
||||
// its first card
|
||||
assert_eq!(slot_card(0, &user)["master_card_id"].as_i64(), Some(40030007));
|
||||
assert_eq!(slot_card(20010001, &user)["master_card_id"].as_i64(), Some(40030007));
|
||||
|
||||
// A tutorial-stage account holds nothing at all
|
||||
let empty = jzon::object!{ "user": {}, "card_list": [] };
|
||||
let card = slot_card(0, &empty);
|
||||
assert_eq!(card["master_card_id"].as_i64(), Some(DEFAULT_CARD));
|
||||
assert_eq!(card["id"].as_i64(), Some(DEFAULT_CARD));
|
||||
assert!(card["evolve"].is_array());
|
||||
assert!(!databases::CARD_LIST[DEFAULT_CARD.to_string()].is_empty());
|
||||
}
|
||||
|
||||
// The imported band proxies to its own character's base card, not to a
|
||||
// single default (the old prefix arithmetic collapsed 14000+ to Honoka)
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod user;
|
||||
pub mod starter;
|
||||
|
||||
use rusqlite::params;
|
||||
use lazy_static::lazy_static;
|
||||
@@ -787,6 +788,37 @@ pub fn export_user(token: &str) -> Option<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 {
|
||||
// If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over.
|
||||
let dead_uids = DATABASE.lock_and_select_all("
|
||||
@@ -802,21 +834,7 @@ pub fn purge_accounts() -> usize {
|
||||
for uid in dead_uids.members() {
|
||||
let user_id = uid.as_i64().unwrap();
|
||||
println!("Removing dead UID: {}", user_id);
|
||||
crate::database::gree::delete_uuid(user_id);
|
||||
DATABASE.lock_and_exec("DELETE FROM userdata WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM userhome WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM missions WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM loginbonus WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM sifcards WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM friends WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM chats WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM exchange WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM event WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM eventloginbonus WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM server_data WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM tokens WHERE user_id=?1", params!(user_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(user_id));
|
||||
delete_account(user_id);
|
||||
}
|
||||
DATABASE.lock_and_exec("VACUUM", params!());
|
||||
crate::database::gree::setup();
|
||||
|
||||
465
src/router/userdata/starter.rs
Normal file
465
src/router/userdata/starter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
|
||||
custom_songs: !crate::router::custom_song::disabled(),
|
||||
custom_cards: !crate::router::custom_card::disabled(),
|
||||
custom_3dmv: !crate::router::custom_3dmv::disabled(),
|
||||
arcade: !crate::router::arcade::disabled(),
|
||||
links: {
|
||||
global: args.global_android,
|
||||
japan: args.japan_android,
|
||||
@@ -483,7 +484,11 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
||||
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
|
||||
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|
||||
|| permissions::has(uid, permissions::PERMISSION_REVOKE),
|
||||
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE)
|
||||
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE),
|
||||
// Cabinets are server hardware, not user content: there is no
|
||||
// finer-grained arcade scope, so managing them takes the top-level
|
||||
// one that every --owner uid holds implicitly
|
||||
can_manage_arcade: !crate::router::arcade::disabled() && permissions::has(uid, permissions::ALL)
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
@@ -557,6 +562,84 @@ pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse {
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
// The operator's cabinet list. Owner scope only: a machine row names the two
|
||||
// accounts a cabinet owns, and removing one deletes them.
|
||||
pub fn list_arcade_machines(req: HttpRequest) -> HttpResponse {
|
||||
if crate::router::arcade::disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
};
|
||||
if !permissions::has(uid, permissions::ALL) {
|
||||
return error("You do not have permission to manage arcade machines");
|
||||
}
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
machines: crate::router::arcade::webui_machines()
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse {
|
||||
if crate::router::arcade::disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
};
|
||||
if !permissions::has(uid, permissions::ALL) {
|
||||
return error("You do not have permission to manage arcade machines");
|
||||
}
|
||||
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||
if let Err(e) = crate::router::arcade::webui_remove_machine(body["machine_id"].as_str().unwrap_or("")) {
|
||||
return error(&e);
|
||||
}
|
||||
let resp = object!{
|
||||
result: "OK"
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
// The account page's "bind arcade card" form. The cabinet's own endpoint speaks
|
||||
// the encrypted game protocol behind the asset gate, which a browser cannot, so
|
||||
// this is the browser's door onto the same rule - arcade::bind_card, not a copy
|
||||
// of it. A webui session is required on top of the transfer code and password:
|
||||
// the page is behind login anyway, and the extra proof costs nothing.
|
||||
pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse {
|
||||
if crate::router::arcade::disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
if session_uid(&req).is_none() {
|
||||
return error("Not logged in");
|
||||
}
|
||||
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||
match crate::router::arcade::bind_card(
|
||||
body["card_id"].as_str().unwrap_or(""),
|
||||
body["migrationCode"].as_str().unwrap_or(""),
|
||||
body["pass"].as_str().unwrap_or("")
|
||||
) {
|
||||
Ok(user_id) => {
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
user_id: user_id
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
},
|
||||
Err(reason) => error(&reason)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
||||
let token = get_login_token(&req);
|
||||
if token.is_none() {
|
||||
|
||||
Reference in New Issue
Block a user