Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78362608c8 | ||
|
|
8c58ff5b01 | ||
|
|
536e66811f | ||
|
|
5a55bbf32a | ||
|
|
02afd15890 | ||
|
|
ee4ce5fed4 | ||
|
|
9897a5d04a | ||
|
|
b1b70ab4c1 | ||
|
|
99ba099d1b | ||
|
|
f1c8e96fbe | ||
|
|
abbfb6027a | ||
|
|
b38aec4ecf | ||
|
|
0b137effb5 | ||
|
|
b08af4deb7 | ||
|
|
bd6ae99888 | ||
|
|
500159ecf5 | ||
|
|
33eb7a4002 | ||
|
|
29bf71eb9b | ||
|
|
7ef418fc51 | ||
|
|
e387f1ff0e | ||
|
|
125061863a | ||
|
|
dfc29c70e4 | ||
|
|
8270662666 | ||
|
|
e258a8ca42 | ||
|
|
3e58691c34 | ||
|
|
2fe5ecd53e | ||
|
|
844a8f381b | ||
|
|
49b2fbcb56 | ||
|
|
78e1f1d1bb | ||
|
|
2d9ea66ee3 | ||
|
|
59193d92f4 | ||
|
|
b92acb7a00 | ||
|
|
971aa32361 | ||
|
|
2ceff8ffc6 | ||
|
|
ea39c12a9e | ||
|
|
79ae274bfe | ||
|
|
fd8416b893 | ||
|
|
85f8b489d6 | ||
|
|
f881a73dbd | ||
|
|
9e5228e9f5 | ||
|
|
1d12a9415c | ||
|
|
4dcf73e5d4 | ||
|
|
06c9273151 | ||
|
|
d975b87799 | ||
|
|
cb40d74c2c | ||
|
|
8f7346d09e |
@@ -12,6 +12,7 @@ ndk/
|
||||
.DS_Store
|
||||
custom_songs/
|
||||
custom_cards/
|
||||
/custom_3dmv/
|
||||
|
||||
# local-only trees — never commit (35GB between them)
|
||||
/android/
|
||||
|
||||
@@ -1085,7 +1085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ew"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"actix-multipart",
|
||||
"actix-web",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ew"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
exclude = ["assets", "*.db"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=webui/index.html");
|
||||
println!("cargo:rerun-if-changed=webui");
|
||||
|
||||
let target = std::env::var("TARGET").unwrap();
|
||||
if target == "aarch64-linux-android" {
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
DISABLE_EXPORTS: false # Will disable account exports
|
||||
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
|
||||
#ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
|
||||
#NERF_CUSTOM_CARDS: true # Cap custom card stats below the official maxima and skills at 80% of the official range (new uploads rejected, existing cards clamped in responses); needs ENABLE_CUSTOM_CARDS
|
||||
#ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints)
|
||||
#OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards
|
||||
#PURGE: false # Purge dead user accounts on startup
|
||||
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
||||
@@ -30,6 +32,7 @@ services:
|
||||
#EN_ANDROID_ASSET_HASH: ""
|
||||
#EN_IOS_ASSET_HASH: ""
|
||||
#WINDOWS_ASSET_HASH: "" # Windows asset hash (JP only; GL Windows is unused)
|
||||
#FORCE_UPDATES: "true" # Force outdated clients to update
|
||||
|
||||
# Everything below is for the "Help" page
|
||||
#ANDROID_GLOBAL: "link.to/patched/android/global.apk"
|
||||
|
||||
@@ -15,6 +15,10 @@ args=(
|
||||
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
||||
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
||||
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
|
||||
[ "${NERF_CUSTOM_CARDS:-}" = "true" ] && args+=(--nerf-custom-cards)
|
||||
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
|
||||
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
|
||||
[ "${FORCE_UPDATES:-}" = "true" ] && args+=(--force-updates)
|
||||
|
||||
add_opt() {
|
||||
local value="$1" flag="$2"
|
||||
@@ -34,6 +38,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
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
pub mod gree;
|
||||
pub mod custom_song;
|
||||
pub mod custom_card;
|
||||
pub mod custom_group;
|
||||
pub mod custom_3dmv;
|
||||
pub mod permissions;
|
||||
pub mod announcements;
|
||||
pub mod arcade;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
use lazy_static::lazy_static;
|
||||
use rusqlite::params;
|
||||
use jzon::{array, object, JsonValue};
|
||||
|
||||
use crate::router::global;
|
||||
use crate::sql::SQLite;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATABASE: SQLite = SQLite::new("announcements.db", setup_tables);
|
||||
}
|
||||
|
||||
// 1 = notice, 2 = update, 3 = bug
|
||||
pub const CATEGORIES: &[i64] = &[1, 2, 3];
|
||||
|
||||
pub const TYPES: &[&str] = &["news", "event", "gacha", "maintenance", "shop", "others"];
|
||||
|
||||
pub fn is_valid_category(category: i64) -> bool {
|
||||
CATEGORIES.contains(&category)
|
||||
}
|
||||
|
||||
pub fn is_valid_type(kind: &str) -> bool {
|
||||
TYPES.contains(&kind)
|
||||
}
|
||||
|
||||
fn setup_tables(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
banner BLOB,
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
visible INTEGER NOT NULL DEFAULT 1,
|
||||
published_at BIGINT NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
").unwrap();
|
||||
}
|
||||
|
||||
pub enum Banner {
|
||||
Keep,
|
||||
Clear,
|
||||
Set(Vec<u8>)
|
||||
}
|
||||
|
||||
fn row_to_json(row: &rusqlite::Row) -> rusqlite::Result<JsonValue> {
|
||||
Ok(object!{
|
||||
id: row.get::<usize, i64>(0)?,
|
||||
category: row.get::<usize, i64>(1)?,
|
||||
type: row.get::<usize, String>(2)?,
|
||||
title: row.get::<usize, String>(3)?,
|
||||
body: row.get::<usize, String>(4)?,
|
||||
has_banner: row.get::<usize, i64>(5)? != 0,
|
||||
updated: row.get::<usize, i64>(6)? != 0,
|
||||
visible: row.get::<usize, i64>(7)? != 0,
|
||||
published_at: row.get::<usize, i64>(8)?,
|
||||
created_by: row.get::<usize, i64>(9)?,
|
||||
created_at: row.get::<usize, i64>(10)?
|
||||
})
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "id, category, type, title, body, banner IS NOT NULL, updated, visible, published_at, created_by, created_at";
|
||||
|
||||
fn query(where_clause: &str, args: &[&dyn rusqlite::ToSql]) -> JsonValue {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
let sql = format!("SELECT {COLUMNS} FROM announcements {where_clause} ORDER BY published_at DESC, id DESC");
|
||||
let Ok(mut stmt) = conn.prepare(&sql) else {
|
||||
return array![];
|
||||
};
|
||||
let Ok(mapped) = stmt.query_map(args, |row| row_to_json(row)) else {
|
||||
return array![];
|
||||
};
|
||||
let mut rv = array![];
|
||||
for row in mapped.flatten() {
|
||||
rv.push(row).unwrap();
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
pub fn list_category(category: i64) -> JsonValue {
|
||||
query("WHERE visible=1 AND category=?1", params!(category))
|
||||
}
|
||||
|
||||
pub fn get_all() -> JsonValue {
|
||||
query("", params!())
|
||||
}
|
||||
|
||||
pub fn get(id: i64) -> Option<JsonValue> {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
let sql = format!("SELECT {COLUMNS} FROM announcements WHERE id=?1");
|
||||
conn.query_row(&sql, params!(id), |row| row_to_json(row)).ok()
|
||||
}
|
||||
|
||||
pub fn get_banner(id: i64) -> Option<Vec<u8>> {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
|
||||
}
|
||||
|
||||
pub fn get_public_banner(id: i64) -> Option<Vec<u8>> {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND visible=1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
|
||||
}
|
||||
|
||||
pub fn visible_ids(category: Option<i64>) -> Vec<i64> {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
let (sql, args): (&str, Vec<&dyn rusqlite::ToSql>) = match &category {
|
||||
Some(c) => ("SELECT id FROM announcements WHERE visible=1 AND category=?1", vec![c]),
|
||||
None => ("SELECT id FROM announcements WHERE visible=1", vec![])
|
||||
};
|
||||
let Ok(mut stmt) = conn.prepare(sql) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(mapped) = stmt.query_map(args.as_slice(), |row| row.get::<usize, i64>(0)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
mapped.flatten().collect()
|
||||
}
|
||||
|
||||
pub fn latest_published_at() -> i64 {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.query_row("SELECT MAX(published_at) FROM announcements WHERE visible=1", params!(), |row| row.get::<usize, i64>(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn create(category: i64, kind: &str, title: &str, body: &str, banner: Option<Vec<u8>>, updated: bool, visible: bool, published_at: i64, created_by: i64) -> i64 {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO announcements (category, type, title, body, banner, updated, visible, published_at, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params!(category, kind, title, body, banner, updated as i64, visible as i64, published_at, created_by, global::timestamp() as i64)
|
||||
).unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
pub fn update(id: i64, category: i64, kind: &str, title: &str, body: &str, banner: Banner, updated: bool, visible: bool, published_at: i64) {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE announcements SET category=?2, type=?3, title=?4, body=?5, updated=?6, visible=?7, published_at=?8 WHERE id=?1",
|
||||
params!(id, category, kind, title, body, updated as i64, visible as i64, published_at)
|
||||
).unwrap();
|
||||
match banner {
|
||||
Banner::Keep => {},
|
||||
Banner::Clear => { conn.execute("UPDATE announcements SET banner=NULL WHERE id=?1", params!(id)).unwrap(); },
|
||||
Banner::Set(bytes) => { conn.execute("UPDATE announcements SET banner=?2 WHERE id=?1", params!(id, bytes)).unwrap(); }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(id: i64) {
|
||||
DATABASE.lock_and_exec("DELETE FROM announcements WHERE id=?1", params!(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// more tests??? This is probably a good thing but man haha
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wipe() {
|
||||
DATABASE.lock_and_exec("DELETE FROM announcements", params!());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_list_and_category_filter() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe();
|
||||
|
||||
let a = create(1, "news", "First", "<p>body</p>", None, false, true, 1000, 42);
|
||||
let b = create(1, "event", "Second", "body", Some(vec![1, 2, 3]), true, true, 2000, 42);
|
||||
let c = create(2, "gacha", "Other tab", "body", None, false, true, 1500, 42);
|
||||
let hidden = create(1, "news", "Draft", "body", None, false, false, 3000, 42);
|
||||
|
||||
// One tab, visible only, newest first
|
||||
let cat1 = list_category(1);
|
||||
assert_eq!(cat1.len(), 2);
|
||||
assert_eq!(cat1[0]["id"].as_i64(), Some(b));
|
||||
assert_eq!(cat1[1]["id"].as_i64(), Some(a));
|
||||
assert!(cat1.members().all(|row| row["id"].as_i64() != Some(hidden)));
|
||||
|
||||
// has_banner reflects the blob without carrying it
|
||||
assert_eq!(cat1[0]["has_banner"].as_bool(), Some(true));
|
||||
assert_eq!(cat1[1]["has_banner"].as_bool(), Some(false));
|
||||
assert_eq!(get_banner(b), Some(vec![1, 2, 3]));
|
||||
assert_eq!(get_banner(a), None);
|
||||
|
||||
// The other tab is untouched, the admin view sees the draft too
|
||||
assert_eq!(list_category(2).len(), 1);
|
||||
assert_eq!(list_category(2)[0]["id"].as_i64(), Some(c));
|
||||
assert_eq!(get_all().len(), 4);
|
||||
|
||||
assert_eq!(latest_published_at(), 2000);
|
||||
let mut visible = visible_ids(None);
|
||||
visible.sort();
|
||||
assert_eq!(visible, vec![a, b, c]);
|
||||
let mut cat1_ids = visible_ids(Some(1));
|
||||
cat1_ids.sort();
|
||||
assert_eq!(cat1_ids, vec![a, b]);
|
||||
|
||||
wipe();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rewrites_and_banner_transitions() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe();
|
||||
|
||||
let id = create(1, "news", "Title", "body", Some(vec![9]), false, true, 1000, 7);
|
||||
update(id, 3, "maintenance", "New title", "new body", Banner::Keep, true, true, 5000);
|
||||
let row = get(id).unwrap();
|
||||
assert_eq!(row["category"].as_i64(), Some(3));
|
||||
assert_eq!(row["type"].as_str(), Some("maintenance"));
|
||||
assert_eq!(row["title"].as_str(), Some("New title"));
|
||||
assert_eq!(row["updated"].as_bool(), Some(true));
|
||||
assert_eq!(row["published_at"].as_i64(), Some(5000));
|
||||
// Keep left the blob in place
|
||||
assert_eq!(get_banner(id), Some(vec![9]));
|
||||
|
||||
update(id, 3, "maintenance", "New title", "new body", Banner::Set(vec![4, 5]), true, true, 5000);
|
||||
assert_eq!(get_banner(id), Some(vec![4, 5]));
|
||||
update(id, 3, "maintenance", "New title", "new body", Banner::Clear, true, false, 5000);
|
||||
assert_eq!(get_banner(id), None);
|
||||
assert_eq!(get(id).unwrap()["visible"].as_bool(), Some(false));
|
||||
|
||||
delete(id);
|
||||
assert!(get(id).is_none());
|
||||
wipe();
|
||||
}
|
||||
|
||||
// Ids are sequential, so the player-facing banner route is pollable: a
|
||||
// draft's banner must be reachable by the admin lookup and by nothing else
|
||||
#[test]
|
||||
fn a_drafts_banner_is_admin_only() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe();
|
||||
|
||||
let published = create(1, "news", "Live", "body", Some(vec![1, 2]), false, true, 1000, 42);
|
||||
let draft = create(1, "news", "Unannounced", "body", Some(vec![7, 7]), false, false, 2000, 42);
|
||||
|
||||
assert_eq!(get_banner(draft), Some(vec![7, 7]));
|
||||
assert_eq!(get_public_banner(draft), None);
|
||||
assert_eq!(get_public_banner(published), Some(vec![1, 2]));
|
||||
|
||||
// Publishing it makes the banner reachable, hiding it again takes it back
|
||||
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, true, 2000);
|
||||
assert_eq!(get_public_banner(draft), Some(vec![7, 7]));
|
||||
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, false, 2000);
|
||||
assert_eq!(get_public_banner(draft), None);
|
||||
|
||||
wipe();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vocabulary_is_wellformed() {
|
||||
for category in CATEGORIES {
|
||||
assert!(is_valid_category(*category));
|
||||
}
|
||||
assert!(!is_valid_category(0));
|
||||
assert!(!is_valid_category(4));
|
||||
for kind in TYPES {
|
||||
assert!(is_valid_type(kind));
|
||||
}
|
||||
assert!(!is_valid_type("explosion"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
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.
|
||||
//
|
||||
// Which account a card names lives in userdata.db (userdata::user::migration,
|
||||
// the `cards` table): linking a card is a plain account feature and works with
|
||||
// this module off. What lives here is only the cabinet side of a card:
|
||||
// `card_sessions` is the "which cabinet is this card sitting at" record -
|
||||
// `last_machine_id` / `last_session` attribute a card account's play to the
|
||||
// machine that most recently ran a session for it - and `session_until` is 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. Re-linking a card clears its row (card_relinked).
|
||||
//
|
||||
// `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 card_sessions (
|
||||
card_id TEXT NOT NULL PRIMARY KEY,
|
||||
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();
|
||||
// Databases from before the card mapping moved to userdata.db carry a
|
||||
// `cards` table here. Its mappings move over once, its windows become
|
||||
// card_sessions rows, and the table goes.
|
||||
if conn.prepare("SELECT user_id FROM cards LIMIT 1;").is_ok() {
|
||||
println!("Moving arcade card mappings to userdata");
|
||||
let has_sessions = conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_ok();
|
||||
let query = if has_sessions {
|
||||
"SELECT card_id, user_id, created, last_machine_id, last_session, session_until FROM cards"
|
||||
} else {
|
||||
"SELECT card_id, user_id, created, '', 0, 0 FROM cards"
|
||||
};
|
||||
let mut stmt = conn.prepare(query).unwrap();
|
||||
let rows: Vec<(String, i64, i64, String, i64, i64)> = stmt.query_map([], |row| Ok((
|
||||
row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?
|
||||
))).unwrap().flatten().collect();
|
||||
drop(stmt);
|
||||
for (card_id, user_id, created, last_machine_id, last_session, session_until) in rows {
|
||||
crate::router::userdata::user::migration::import_card(&card_id, user_id, created);
|
||||
if !last_machine_id.is_empty() || session_until != 0 {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!(card_id, last_machine_id, last_session, session_until)
|
||||
).unwrap();
|
||||
}
|
||||
}
|
||||
conn.execute("DROP TABLE cards;", []).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
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("DELETE FROM card_sessions WHERE last_machine_id=?1", params!(machine_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id));
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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(
|
||||
"INSERT INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(card_id) DO UPDATE SET last_machine_id=?2, last_session=?3, session_until=?4",
|
||||
params!(card_id, machine_id, global::timestamp() as i64, until)
|
||||
);
|
||||
}
|
||||
|
||||
// A card that changed hands takes nothing of the previous holder's credit with it
|
||||
pub fn clear_card_session(card_id: &str) {
|
||||
DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE card_id=?1", params!(card_id));
|
||||
}
|
||||
|
||||
fn placeholders(count: usize) -> String {
|
||||
(1..=count).map(|i| format!("?{}", i)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
// Of these cards (an account's, userdata::user::migration::cards_of_account),
|
||||
// the one whose cabinet session is still open at `now`, as (card id, when the
|
||||
// session started). None when none of them is at a cabinet - which is every
|
||||
// phone account, and every card between credits.
|
||||
pub fn live_card_session(cards: &[String], now: i64) -> Option<(String, i64)> {
|
||||
if cards.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
|
||||
let sql = format!(
|
||||
"SELECT card_id, last_session FROM card_sessions WHERE card_id IN ({}) AND session_until>?{} ORDER BY session_until DESC LIMIT 1",
|
||||
placeholders(cards.len()), cards.len() + 1
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let mut args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
|
||||
args.push(&now);
|
||||
stmt.query_row(args.as_slice(), |row| Ok((row.get::<usize, String>(0)?, row.get::<usize, i64>(1)?))).ok()
|
||||
}
|
||||
|
||||
// A live starting inside the window pushes its end back, so a credit whose songs
|
||||
// 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 card_sessions 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 card_sessions 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 any of these cards. None
|
||||
// when none of them has ever been at a cabinet
|
||||
pub fn last_machine_of_cards(cards: &[String]) -> Option<String> {
|
||||
if cards.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
|
||||
let sql = format!(
|
||||
"SELECT last_machine_id FROM card_sessions WHERE card_id IN ({}) AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1",
|
||||
placeholders(cards.len())
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
|
||||
let machine_id: String = stmt.query_row(args.as_slice(), |row| row.get(0)).ok()?;
|
||||
if machine_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(machine_id)
|
||||
}
|
||||
|
||||
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's cabinet record follows the card: it is attributed to the cabinet
|
||||
// the card last sat at, a re-link clears it, and it dies with the machine
|
||||
#[test]
|
||||
fn a_cards_cabinet_record_follows_the_card() {
|
||||
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 cards = vec!["0123456789012345".to_string()];
|
||||
let card = cards[0].as_str();
|
||||
|
||||
// Never at a cabinet yet
|
||||
assert!(last_machine_of_cards(&cards).is_none());
|
||||
assert!(live_card_session(&[], 0).is_none());
|
||||
|
||||
let now = global::timestamp() as i64;
|
||||
open_card_session(card, &machine, now + 60);
|
||||
assert_eq!(last_machine_of_cards(&cards).as_deref(), Some(machine.as_str()));
|
||||
|
||||
// A re-link forgets the cabinet and the window the previous account's credit paid for
|
||||
clear_card_session(card);
|
||||
assert!(last_machine_of_cards(&cards).is_none());
|
||||
assert!(live_card_session(&cards, now).is_none(), "a re-link 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 and its card records with it
|
||||
open_card_session(card, &machine, now + 60);
|
||||
delete_machine(&machine);
|
||||
assert_eq!(play_count(&machine), 0);
|
||||
assert!(last_machine_of_cards(&cards).is_none());
|
||||
assert!(live_card_session(&cards, now).is_none(), "a retired cabinet left a credit open");
|
||||
}
|
||||
|
||||
// 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
|
||||
#[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 cards = vec!["1212121212121212".to_string()];
|
||||
let card = cards[0].as_str();
|
||||
let now = global::timestamp() as i64;
|
||||
|
||||
// A card with no session behind it is not a cabinet session
|
||||
assert!(live_card_session(&cards, now).is_none());
|
||||
|
||||
open_card_session(card, &machine, now + 600);
|
||||
let (open_card, opened) = live_card_session(&cards, 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(&cards, now + 599).is_some());
|
||||
assert!(live_card_session(&cards, now + 600).is_none(), "the window outlived its own expiry");
|
||||
assert!(live_card_session(&cards, now + 601).is_none());
|
||||
|
||||
// A live inside it pushes it forward, and only forward
|
||||
extend_card_session(card, now + 1200);
|
||||
assert!(live_card_session(&cards, now + 900).is_some());
|
||||
extend_card_session(card, now + 300);
|
||||
assert!(live_card_session(&cards, now + 900).is_some(), "an extension moved the window backwards");
|
||||
|
||||
// Another card is untouched by any of it
|
||||
let other = vec!["3434343434343434".to_string()];
|
||||
assert!(live_card_session(&other, now).is_none());
|
||||
|
||||
delete_machine(&machine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use lazy_static::lazy_static;
|
||||
use rusqlite::params;
|
||||
use jzon::{array, JsonValue};
|
||||
|
||||
use crate::sql::SQLite;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATABASE: SQLite = SQLite::new("custom_3dmv.db", setup_tables);
|
||||
}
|
||||
|
||||
// mv_id is its own namespace: it never appears where a music_id or a card id
|
||||
// could, so the band only has to avoid colliding with itself. Ids are never
|
||||
// reused after a delete (high-water mark below), so a client's cached copy of
|
||||
// a dead id can't get confused with a later upload
|
||||
pub const FIRST_MV_ID: i64 = 20001;
|
||||
pub const LAST_MV_ID: i64 = 99_999;
|
||||
|
||||
// One JSON blob per MV, in the exact shape /api/custom_3dmv/list serves -
|
||||
// except `published`, which lives in its own column (the catalog filter
|
||||
// queries it) and is only injected for the webui manage view
|
||||
fn setup_tables(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS mvs (
|
||||
mv_id BIGINT NOT NULL PRIMARY KEY,
|
||||
music_id BIGINT NOT NULL,
|
||||
owner_id BIGINT NOT NULL,
|
||||
mv TEXT NOT NULL,
|
||||
published INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS revision (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
revision BIGINT NOT NULL,
|
||||
last_mv_id BIGINT NOT NULL
|
||||
);
|
||||
").unwrap();
|
||||
}
|
||||
|
||||
pub fn get_revision() -> i64 {
|
||||
DATABASE.lock_and_select("SELECT revision FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
// Bumped on every upload/update/delete/publish change so the client can tell
|
||||
// its cached catalog is stale
|
||||
pub fn bump_revision() {
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 1, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!());
|
||||
}
|
||||
|
||||
// last_mv_id is the high-water mark and only ever rises, so MAX() over the
|
||||
// live rows is a floor, not the answer
|
||||
pub fn next_mv_id() -> i64 {
|
||||
let issued = DATABASE.lock_and_select("SELECT last_mv_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||
let max = DATABASE.lock_and_select("SELECT MAX(mv_id) FROM mvs", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||
std::cmp::max(std::cmp::max(issued, max), FIRST_MV_ID - 1) + 1
|
||||
}
|
||||
|
||||
// The row and the high-water mark are one write: a failure between them leaves an
|
||||
// MV whose id the next upload would reissue, and the failure has to reach the
|
||||
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
|
||||
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO mvs (mv_id, music_id, owner_id, mv, published) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!(mv_id, music_id, owner_id, jzon::stringify(mv.clone()), published as i64)
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_mv_id=?1", params!(mv_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// The catalog blob only. The owner and the published flag live in their own
|
||||
// columns and are untouched here; music_id is fixed for the life of the MV
|
||||
pub fn update_mv(mv_id: i64, mv: &JsonValue) {
|
||||
DATABASE.lock_and_exec("UPDATE mvs SET mv=?1 WHERE mv_id=?2", params!(jzon::stringify(mv.clone()), mv_id));
|
||||
}
|
||||
|
||||
pub fn delete_mv(mv_id: i64) {
|
||||
DATABASE.lock_and_exec("DELETE FROM mvs WHERE mv_id=?1", params!(mv_id));
|
||||
}
|
||||
|
||||
pub fn get_mv(mv_id: i64) -> Option<JsonValue> {
|
||||
let mv = DATABASE.lock_and_select("SELECT mv FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?;
|
||||
jzon::parse(&mv).ok()
|
||||
}
|
||||
|
||||
pub fn get_mv_owner(mv_id: i64) -> Option<i64> {
|
||||
DATABASE.lock_and_select("SELECT owner_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
pub fn get_mv_music_id(mv_id: i64) -> Option<i64> {
|
||||
DATABASE.lock_and_select("SELECT music_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
pub fn is_published(mv_id: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT published FROM mvs WHERE mv_id=?1", params!(mv_id)).unwrap_or_default() == "1"
|
||||
}
|
||||
|
||||
pub fn set_published(mv_id: i64, published: bool) {
|
||||
DATABASE.lock_and_exec("UPDATE mvs SET published=?1 WHERE mv_id=?2", params!(published as i64, mv_id));
|
||||
}
|
||||
|
||||
pub fn mv_count_for_owner(owner_id: i64) -> i64 {
|
||||
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_blobs(rows: JsonValue) -> JsonValue {
|
||||
let mut rv = array![];
|
||||
for data in rows.members() {
|
||||
if let Ok(parsed) = jzon::parse(&data.to_string()) {
|
||||
rv.push(parsed).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// The MV catalog this user is served: every published MV plus their own
|
||||
// drafts, filtered against `music_ids` - the music ids the SAME user's
|
||||
// custom-song catalog delivers. The closure is what keeps the response
|
||||
// referentially sound: a served MV must never name a music_id the song
|
||||
// catalog failed to deliver (a published MV for someone else's private song
|
||||
// stays invisible)
|
||||
pub fn get_mvs_for_user(user_id: i64, music_ids: &[i64]) -> JsonValue {
|
||||
let rows = parse_blobs(DATABASE.lock_and_select_all(
|
||||
"SELECT mv FROM mvs WHERE published=1 OR owner_id=?1 ORDER BY mv_id",
|
||||
params!(user_id)
|
||||
).unwrap_or(array![]));
|
||||
let mut rv = array![];
|
||||
for mv in rows.members() {
|
||||
if music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) {
|
||||
rv.push(mv.clone()).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// MV blobs plus the flag column, for the webui manage view
|
||||
pub fn get_mvs_by_owner(owner_id: i64) -> JsonValue {
|
||||
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![]));
|
||||
let mut rv = array![];
|
||||
for mv in rows.members() {
|
||||
let mut mv = mv.clone();
|
||||
mv["published"] = is_published(mv["mv_id"].as_i64().unwrap_or(0)).into();
|
||||
rv.push(mv).unwrap();
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// The webui MV browser: published MVs whose song the viewer can see, plus the
|
||||
// owner id so the page can label the uploader
|
||||
pub fn get_browse_mvs(viewer_music_ids: &[i64]) -> JsonValue {
|
||||
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE published=1 ORDER BY mv_id", params!()).unwrap_or(array![]));
|
||||
let mut rv = array![];
|
||||
for mv in rows.members() {
|
||||
if !viewer_music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) {
|
||||
continue;
|
||||
}
|
||||
let mut mv = mv.clone();
|
||||
mv["owner_id"] = get_mv_owner(mv["mv_id"].as_i64().unwrap_or(0)).unwrap_or(0).into();
|
||||
rv.push(mv).unwrap();
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// Every MV attached to a song, for the delete cascade
|
||||
pub fn mv_ids_for_music(music_id: i64) -> Vec<i64> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE music_id=?1 ORDER BY mv_id", params!(music_id)).unwrap_or(array![]);
|
||||
rows.members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// Which of these candidate ids no longer exist. Only the MV band is ever
|
||||
// considered and ids are never reused, so a wipe is final. An MV that's
|
||||
// merely unpublished still has its row - only genuinely deleted ids return
|
||||
pub fn dead_mv_ids(candidates: &JsonValue) -> JsonValue {
|
||||
let mut ids: Vec<i64> = Vec::new();
|
||||
for id in candidates.members() {
|
||||
let Some(id) = id.as_i64() else { continue; };
|
||||
if (FIRST_MV_ID..=LAST_MV_ID).contains(&id) && !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return array![];
|
||||
}
|
||||
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let alive = DATABASE.lock_and_select_all(&format!("SELECT mv_id FROM mvs WHERE mv_id IN ({})", list), params!()).unwrap_or(array![]);
|
||||
let mut rv = array![];
|
||||
for id in ids {
|
||||
if !alive.contains(id) {
|
||||
rv.push(id).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// Every stored catalog blob, unparsed and unfiltered. Only the startup blob
|
||||
// sweep needs the whole table, and a read failure must never look like an
|
||||
// empty catalog (the sweep would then delete every blob), so this returns
|
||||
// None rather than an empty array on error
|
||||
pub fn all_mv_blobs() -> Option<JsonValue> {
|
||||
DATABASE.lock_and_select_all("SELECT mv FROM mvs ORDER BY mv_id", params!()).ok()
|
||||
}
|
||||
|
||||
// Blobs are content-addressed and may be shared between MVs (or roles), so
|
||||
// every candidate row is checked - a single-row scan could land on a
|
||||
// coincidental substring match and miss the real reference.
|
||||
//
|
||||
// Returns Result and never folds an error into "not referenced": the caller
|
||||
// unlinks on false, and a read that failed (SQLITE_BUSY under a concurrent
|
||||
// write, a corrupt page) says nothing about whether the blob is live. The
|
||||
// startup sweep is deliberately fail-closed; this is its online half
|
||||
pub fn blob_in_use(md5: &str) -> Result<bool, rusqlite::Error> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5)))?;
|
||||
for blob in rows.members() {
|
||||
let Ok(mv) = jzon::parse(&blob.to_string()) else {
|
||||
// An unparseable row could reference anything - assume it does
|
||||
return Ok(true);
|
||||
};
|
||||
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Two-step content-addressed lookup for the data route: the LIKE scan finds a
|
||||
// candidate row cheaply, then the files array confirms the md5 is really a
|
||||
// stored file of a live MV (and not a substring coincidence elsewhere in the
|
||||
// blob). The blob path itself derives from the md5
|
||||
pub fn find_blob_by_md5(md5: &str) -> bool {
|
||||
// A read failure here means "cannot prove this blob is served", which the
|
||||
// route turns into a 404 - the client re-requests. Unlike the GC, guessing
|
||||
// wrong in this direction costs nothing permanent
|
||||
blob_in_use(md5).unwrap_or(false)
|
||||
}
|
||||
|
||||
// Every MV this account uploaded, for the account-deletion purge
|
||||
pub fn mv_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![]);
|
||||
rows.members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The stored bytes an MV's files account for. Blobs are shared, so two MVs that
|
||||
// carry the same model both count it: the quota is "what this account asked the
|
||||
// server to store", not a dedup-aware disk figure
|
||||
pub fn mv_bytes(files: &JsonValue) -> i64 {
|
||||
files.members().map(|file| file["size"].as_i64().unwrap_or(0)).sum()
|
||||
}
|
||||
|
||||
// What this account's MVs already occupy, optionally ignoring one (the MV being
|
||||
// replaced by an in-place edit, whose new size is counted instead)
|
||||
pub fn owner_bytes(owner_id: i64, excluding_mv_id: i64) -> i64 {
|
||||
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]));
|
||||
rows.members()
|
||||
.filter(|mv| mv["mv_id"].as_i64() != Some(excluding_mv_id))
|
||||
.map(|mv| mv_bytes(&mv["files"]))
|
||||
.sum()
|
||||
}
|
||||
|
||||
// The on-disk database file, so a test can force the read errors the fail-closed
|
||||
// GC paths are built for
|
||||
#[cfg(test)]
|
||||
pub fn test_db_path() -> String {
|
||||
DATABASE.get_path().to_string()
|
||||
}
|
||||
@@ -87,20 +87,29 @@ pub fn next_character_id() -> i64 {
|
||||
std::cmp::max(std::cmp::max(issued, max), FIRST_CHARACTER_ID - 1) + 1
|
||||
}
|
||||
|
||||
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) {
|
||||
DATABASE.lock_and_exec(
|
||||
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
|
||||
);
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id));
|
||||
// The row and the high-water mark are one write: a failure between them leaves a
|
||||
// card whose id the next upload would reissue, and the failure has to reach the
|
||||
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
|
||||
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) {
|
||||
DATABASE.lock_and_exec(
|
||||
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
|
||||
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
|
||||
);
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id));
|
||||
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
|
||||
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// The catalog blob only. The owner and the published/obtainable flags live in
|
||||
@@ -173,6 +182,47 @@ pub fn has_character(master_character_id: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT master_character_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).is_ok()
|
||||
}
|
||||
|
||||
// Every card / character this account uploaded, for the account-deletion purge
|
||||
pub fn card_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT master_card_id FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id))
|
||||
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
pub fn character_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id))
|
||||
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The stored bytes one card / character accounts for: every derived art png
|
||||
// plus, for a character, its voiceline oggs
|
||||
pub fn card_bytes(card: &JsonValue) -> i64 {
|
||||
card["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum()
|
||||
}
|
||||
|
||||
pub fn character_bytes(character: &JsonValue) -> i64 {
|
||||
let art: i64 = character["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum();
|
||||
let voice: i64 = character["voice"].members().map(|line| line["size"].as_i64().unwrap_or(0)).sum();
|
||||
art + voice
|
||||
}
|
||||
|
||||
// What this account's uploads already occupy. Cards and characters share one
|
||||
// quota (they share one storage root), each optionally ignoring the entity being
|
||||
// replaced by an in-place edit, whose new size is counted instead
|
||||
pub fn owner_bytes(owner_id: i64, excluding_card_id: i64, excluding_character_id: i64) -> i64 {
|
||||
let mut total = 0;
|
||||
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
|
||||
if blob["master_card_id"].as_i64() != Some(excluding_card_id) {
|
||||
total += card_bytes(blob);
|
||||
}
|
||||
}
|
||||
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
|
||||
if blob["master_character_id"].as_i64() != Some(excluding_character_id) {
|
||||
total += character_bytes(blob);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn card_count_for_owner(owner_id: i64) -> i64 {
|
||||
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
|
||||
}
|
||||
@@ -300,7 +350,10 @@ pub fn get_browse_cards() -> JsonValue {
|
||||
// considered, and ids are never reused, so official (or imported) cards can't
|
||||
// come back from this and a wipe is final. A card that's merely unpublished
|
||||
// still has its row - only genuinely deleted ids are returned
|
||||
pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
|
||||
// None = the catalog could not be read (or is entirely empty while players still hold
|
||||
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
|
||||
// like one where every id is dead, and get_acc saves the pruned userdata.
|
||||
pub fn dead_card_ids(candidates: &JsonValue) -> Option<JsonValue> {
|
||||
let mut ids: Vec<i64> = Vec::new();
|
||||
for id in candidates.members() {
|
||||
let Some(id) = id.as_i64() else { continue; };
|
||||
@@ -309,17 +362,23 @@ pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return array![];
|
||||
return Some(array![]);
|
||||
}
|
||||
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]);
|
||||
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).ok()?;
|
||||
if alive.is_empty() {
|
||||
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM cards", params!()).ok()?;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut rv = array![];
|
||||
for id in ids {
|
||||
if !alive.contains(id) {
|
||||
rv.push(id).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
Some(rv)
|
||||
}
|
||||
|
||||
// The published + obtainable pool the custom gacha banner draws from, per
|
||||
@@ -410,10 +469,10 @@ mod tests {
|
||||
let first = next_card_id();
|
||||
assert!(first >= FIRST_CARD_ID);
|
||||
assert_ne!(first % 10000, 0);
|
||||
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false);
|
||||
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false).unwrap();
|
||||
let second = next_card_id();
|
||||
assert_eq!(second, first + 1);
|
||||
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false);
|
||||
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false).unwrap();
|
||||
delete_card(second);
|
||||
assert_eq!(get_card(second), None);
|
||||
// The high-water mark survives the delete, so the id is retired
|
||||
@@ -421,7 +480,7 @@ mod tests {
|
||||
|
||||
let character = next_character_id();
|
||||
assert!(character >= FIRST_CHARACTER_ID);
|
||||
insert_character(character, 3001, &object!{ "master_character_id": character });
|
||||
insert_character(character, 3001, &object!{ "master_character_id": character }).unwrap();
|
||||
assert_eq!(next_character_id(), character + 1);
|
||||
delete_character(character);
|
||||
assert_eq!(next_character_id(), character + 1);
|
||||
@@ -440,13 +499,13 @@ mod tests {
|
||||
wipe(3004);
|
||||
|
||||
let character = next_character_id();
|
||||
insert_character(character, 3003, &object!{ "master_character_id": character });
|
||||
insert_character(character, 3003, &object!{ "master_character_id": character }).unwrap();
|
||||
let published = next_card_id();
|
||||
let mut blob = card_blob(published, 3);
|
||||
blob["master_character_id"] = character.into();
|
||||
insert_card(published, character, 3003, &blob, true, true);
|
||||
insert_card(published, character, 3003, &blob, true, true).unwrap();
|
||||
let draft = next_card_id();
|
||||
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false);
|
||||
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false).unwrap();
|
||||
|
||||
let owner_view = get_cards_for_user(3003, &[]);
|
||||
assert_eq!(owner_view.len(), 2);
|
||||
@@ -493,13 +552,13 @@ mod tests {
|
||||
wipe(3005);
|
||||
|
||||
let r1 = next_card_id();
|
||||
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true);
|
||||
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true).unwrap();
|
||||
let r3 = next_card_id();
|
||||
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true);
|
||||
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true).unwrap();
|
||||
let unpublished = next_card_id();
|
||||
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true);
|
||||
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true).unwrap();
|
||||
let unobtainable = next_card_id();
|
||||
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false);
|
||||
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false).unwrap();
|
||||
|
||||
assert_eq!(obtainable_card_ids(1), vec![r1]);
|
||||
assert_eq!(obtainable_card_ids(3), vec![r3]);
|
||||
@@ -516,7 +575,7 @@ mod tests {
|
||||
wipe(3007);
|
||||
|
||||
let id = next_card_id();
|
||||
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false);
|
||||
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false).unwrap();
|
||||
assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id)));
|
||||
assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None);
|
||||
|
||||
@@ -524,7 +583,7 @@ mod tests {
|
||||
insert_character(character, 3007, &object!{
|
||||
"master_character_id": character,
|
||||
"art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }]
|
||||
});
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
find_asset_by_md5("aabbccddeeff00112233445566778899"),
|
||||
Some(format!("characters/{}/icon.png", character))
|
||||
@@ -541,15 +600,38 @@ mod tests {
|
||||
wipe(3008);
|
||||
|
||||
let alive = next_card_id();
|
||||
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false);
|
||||
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false).unwrap();
|
||||
let dead = next_card_id();
|
||||
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false);
|
||||
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false).unwrap();
|
||||
delete_card(dead);
|
||||
|
||||
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]);
|
||||
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]).unwrap();
|
||||
assert_eq!(dead_ids.len(), 1);
|
||||
assert_eq!(dead_ids[0].as_i64(), Some(dead));
|
||||
|
||||
wipe(3008);
|
||||
}
|
||||
|
||||
// An unreadable or blank catalog must never read as "every custom card is
|
||||
// dead": get_acc prunes on that answer and saves the pruned userdata
|
||||
#[test]
|
||||
fn dead_ids_are_unknown_when_the_catalog_cannot_be_read() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(3009);
|
||||
let alive = next_card_id();
|
||||
insert_card(alive, 1001, 3009, &card_blob(alive, 1), false, false).unwrap();
|
||||
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
conn.execute("ALTER TABLE cards RENAME TO cards_hidden", ()).unwrap();
|
||||
let unreadable = dead_card_ids(&array![alive]);
|
||||
conn.execute("CREATE TABLE cards (master_card_id BIGINT NOT NULL PRIMARY KEY, master_character_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, card TEXT NOT NULL, rarity INT NOT NULL DEFAULT 1, published INT NOT NULL DEFAULT 0, obtainable INT NOT NULL DEFAULT 0)", ()).unwrap();
|
||||
let blank = dead_card_ids(&array![alive]);
|
||||
conn.execute("DROP TABLE cards", ()).unwrap();
|
||||
conn.execute("ALTER TABLE cards_hidden RENAME TO cards", ()).unwrap();
|
||||
|
||||
assert!(unreadable.is_none(), "an unreadable catalog reported dead cards");
|
||||
assert!(blank.is_none(), "a blank catalog reported every card dead");
|
||||
assert_eq!(dead_card_ids(&array![alive]).unwrap().len(), 0);
|
||||
wipe(3009);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use lazy_static::lazy_static;
|
||||
use rusqlite::params;
|
||||
use jzon::{array, object, JsonValue};
|
||||
use crate::sql::SQLite;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATABASE: SQLite = SQLite::new("custom_groups.db", setup_tables);
|
||||
}
|
||||
|
||||
pub const FIRST_ID: i64 = 10_000;
|
||||
|
||||
fn setup_tables(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch("CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS custom_groups_unique_name ON groups(name COLLATE NOCASE);
|
||||
CREATE TABLE IF NOT EXISTS logos (group_id INTEGER PRIMARY KEY, md5 TEXT NOT NULL, size INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sequence (id INTEGER PRIMARY KEY CHECK (id=1), last_id INTEGER NOT NULL);").unwrap();
|
||||
}
|
||||
|
||||
pub fn list() -> JsonValue {
|
||||
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||
let Ok(mut stmt) = conn.prepare("SELECT id, name, name_en, COALESCE(md5, ''), COALESCE(size, 0) FROM groups LEFT JOIN logos ON group_id=id ORDER BY id") else { return array![]; };
|
||||
let Ok(rows) = stmt.query_map(params![], |row| Ok(object! {
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"name": row.get::<_, String>(1)?,
|
||||
"name_en": row.get::<_, String>(2)?,
|
||||
"logo_md5": row.get::<_, String>(3)?,
|
||||
"logo_size": row.get::<_, i64>(4)?
|
||||
})) else { return array![]; };
|
||||
let mut result = array![];
|
||||
for row in rows.flatten() { result.push(row).unwrap(); }
|
||||
result
|
||||
}
|
||||
|
||||
pub fn set_logo(id: i64, md5: &str, size: i64) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("INSERT INTO logos (group_id, md5, size) VALUES (?1, ?2, ?3) ON CONFLICT(group_id) DO UPDATE SET md5=excluded.md5, size=excluded.size", params!(id, md5, size))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_logo(md5: &str) -> bool {
|
||||
DATABASE.lock_and_select("SELECT group_id FROM logos WHERE md5=?1", params!(md5)).is_ok()
|
||||
}
|
||||
|
||||
pub fn exists(id: i64) -> bool {
|
||||
if id < FIRST_ID { return false; }
|
||||
DATABASE.lock_and_select("SELECT id FROM groups WHERE id=?1", params!(id)).is_ok()
|
||||
}
|
||||
|
||||
pub fn create(name: &str, name_en: &str) -> Result<i64, rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
let last: i64 = conn.query_row("SELECT last_id FROM sequence WHERE id=1", [], |row| row.get(0)).unwrap_or(FIRST_ID - 1);
|
||||
let id = last + 1;
|
||||
conn.execute("INSERT INTO groups (id, name, name_en) VALUES (?1, ?2, ?3)", params!(id, name, name_en))?;
|
||||
conn.execute("INSERT INTO sequence (id, last_id) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET last_id=?1", params!(id))?;
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adds_logo_storage_to_existing_group_database() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL);
|
||||
INSERT INTO groups VALUES (10000, 'Team A', 'Team A');").unwrap();
|
||||
setup_tables(&conn);
|
||||
setup_tables(&conn);
|
||||
assert_eq!(conn.query_row("SELECT name FROM groups WHERE id=10000", [], |row| row.get::<_, String>(0)).unwrap(), "Team A");
|
||||
conn.execute("INSERT INTO logos VALUES (10000, 'test-hash', 42)", []).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,16 @@ pub fn next_music_id() -> i64 {
|
||||
std::cmp::max(std::cmp::max(issued, max), FIRST_MUSIC_ID - 1) + 1
|
||||
}
|
||||
|
||||
pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) {
|
||||
DATABASE.lock_and_exec("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64));
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id));
|
||||
set_shared_users(music_id, shared_with);
|
||||
// The row, the high-water mark and the shared-user list are one write: a failure
|
||||
// between them would leave a song whose id the next upload reissues, and the
|
||||
// failure itself must reach the uploader as an error rather than panic the worker
|
||||
// (lock_and_exec unwraps, lock_and_transact returns)
|
||||
pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64))?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id))?;
|
||||
write_shared_users(conn, music_id, shared_with)
|
||||
})
|
||||
}
|
||||
|
||||
// Replace an existing song's catalog blob in place. The owner, visibility,
|
||||
@@ -119,9 +125,12 @@ pub fn update_song(music_id: i64, song: &JsonValue) {
|
||||
DATABASE.lock_and_exec("UPDATE songs SET song=?1 WHERE music_id=?2", params!(jzon::stringify(song.clone()), music_id));
|
||||
}
|
||||
|
||||
pub fn delete_song(music_id: i64) {
|
||||
DATABASE.lock_and_exec("DELETE FROM songs WHERE music_id=?1", params!(music_id));
|
||||
DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id));
|
||||
pub fn delete_song(music_id: i64) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("DELETE FROM songs WHERE music_id=?1", params!(music_id))?;
|
||||
conn.execute("DELETE FROM shared_users WHERE music_id=?1", params!(music_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_song(music_id: i64) -> Option<JsonValue> {
|
||||
@@ -133,9 +142,11 @@ pub fn get_song_owner(music_id: i64) -> Option<i64> {
|
||||
DATABASE.lock_and_select("SELECT owner_id FROM songs WHERE music_id=?1", params!(music_id)).ok()?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) {
|
||||
DATABASE.lock_and_exec("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id));
|
||||
set_shared_users(music_id, shared_with);
|
||||
pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id))?;
|
||||
write_shared_users(conn, music_id, shared_with)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_downloads_disabled(music_id: i64, downloads_disabled: bool) {
|
||||
@@ -146,11 +157,15 @@ fn get_downloads_disabled(music_id: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT downloads_disabled FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or_default() == "1"
|
||||
}
|
||||
|
||||
fn set_shared_users(music_id: i64, shared_with: &JsonValue) {
|
||||
DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id));
|
||||
// Replace-the-whole-list, inside the caller's transaction: a half-written list is a
|
||||
// song shared with the wrong people
|
||||
fn write_shared_users(conn: &rusqlite::Connection, music_id: i64, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
conn.execute("DELETE FROM shared_users WHERE music_id=?1", params!(music_id))?;
|
||||
for id in shared_with.members() {
|
||||
DATABASE.lock_and_exec("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id.as_i64().unwrap()));
|
||||
let Some(id) = id.as_i64() else { continue; };
|
||||
conn.execute("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_visibility(music_id: i64) -> String {
|
||||
@@ -257,6 +272,75 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||
Some(if english && !name_en.is_empty() { name_en } else { name })
|
||||
}
|
||||
|
||||
// Whether this viewer may fetch the song's per-id asset files (jacket, blur,
|
||||
// chart JSON). Exactly the catalog's visibility rule: public to everyone, private
|
||||
// to its owner, shared to its owner and the shared-user list. Anonymous viewers
|
||||
// (no webui session) are only ever shown public songs.
|
||||
//
|
||||
// The /data/{md5} route stays sessionless on purpose - a 128-bit content hash IS
|
||||
// the capability there - but /assets/{music_id}/{file} is addressed by a
|
||||
// sequential id, so it needs the real rule
|
||||
pub fn asset_visible(music_id: i64, viewer: Option<i64>) -> bool {
|
||||
let Some(owner) = get_song_owner(music_id) else {
|
||||
return false;
|
||||
};
|
||||
let viewer = viewer.unwrap_or(0);
|
||||
if owner == viewer {
|
||||
return true;
|
||||
}
|
||||
match get_visibility(music_id).as_str() {
|
||||
"public" => true,
|
||||
"shared" => get_shared_users(music_id).contains(viewer),
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
// Every song this account uploaded, for the account-deletion purge
|
||||
pub fn music_ids_by_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT music_id FROM songs WHERE owner_id=?1 ORDER BY music_id", params!(owner_id))
|
||||
.unwrap_or(array![])
|
||||
.members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The served bytes one song accounts for: both jackets, every chart and both
|
||||
// audio cues, straight out of the catalog blob that quotes them to the client.
|
||||
// The original upload artifacts kept under original/ are NOT counted (the
|
||||
// catalog does not record their sizes); the per-account cap is set with that
|
||||
// roughly-2x on-disk factor in mind
|
||||
pub fn song_bytes(song: &JsonValue) -> i64 {
|
||||
let mut total = song["jacket_size"].as_i64().unwrap_or(0) + song["jacket_blur_size"].as_i64().unwrap_or(0);
|
||||
for level in song["levels"].members() {
|
||||
total += level["size"].as_i64().unwrap_or(0);
|
||||
}
|
||||
for key in ["play", "select"] {
|
||||
total += song["sound"][key]["size"].as_i64().unwrap_or(0);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// What this account's songs already occupy, optionally ignoring one song (the
|
||||
// one being replaced by an in-place edit, whose new size is counted instead)
|
||||
pub fn owner_bytes(owner_id: i64, excluding_music_id: i64) -> i64 {
|
||||
let songs = DATABASE.lock_and_select_all("SELECT song FROM songs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]);
|
||||
let mut total = 0;
|
||||
for blob in songs.members() {
|
||||
let Ok(song) = jzon::parse(&blob.to_string()) else { continue; };
|
||||
if song["music_id"].as_i64() == Some(excluding_music_id) {
|
||||
continue;
|
||||
}
|
||||
total += song_bytes(&song);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// Whether the song exists and is publicly visible - what lets another
|
||||
// uploader attach cross-feature content (a custom 3D MV) to it. The
|
||||
// existence check comes first: get_visibility defaults to "public" for an
|
||||
// absent row
|
||||
pub fn song_publicly_visible(music_id: i64) -> bool {
|
||||
get_song_owner(music_id).is_some() && get_visibility(music_id) == "public"
|
||||
}
|
||||
|
||||
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
|
||||
DATABASE.lock_and_select_all("
|
||||
SELECT music_id FROM songs
|
||||
@@ -281,7 +365,10 @@ pub fn non_public_music_ids_for(user_id: i64) -> JsonValue {
|
||||
// range is ever considered, so official songs can't come back from this. A song
|
||||
// that's merely private/shared still has its row - only genuinely deleted ids
|
||||
// (which are never reused) are returned
|
||||
pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
|
||||
// None = the catalog could not be read (or is entirely empty while players still hold
|
||||
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
|
||||
// like one where every id is dead, and get_acc saves the pruned userdata.
|
||||
pub fn dead_music_ids(candidates: &JsonValue) -> Option<JsonValue> {
|
||||
let mut ids: Vec<i64> = Vec::new();
|
||||
for id in candidates.members() {
|
||||
let Some(id) = id.as_i64() else { continue; };
|
||||
@@ -290,17 +377,23 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return array![];
|
||||
return Some(array![]);
|
||||
}
|
||||
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]);
|
||||
let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).ok()?;
|
||||
if alive.is_empty() {
|
||||
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM songs", params!()).ok()?;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut rv = array![];
|
||||
for id in ids {
|
||||
if !alive.contains(id) {
|
||||
rv.push(id).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
Some(rv)
|
||||
}
|
||||
|
||||
// Every stored catalog blob, unparsed and unfiltered by visibility. Only the
|
||||
@@ -311,9 +404,32 @@ pub fn all_song_blobs() -> Option<JsonValue> {
|
||||
DATABASE.lock_and_select_all("SELECT song FROM songs ORDER BY music_id", params!()).ok()
|
||||
}
|
||||
|
||||
// Audio files are content-addressed and may be shared between songs
|
||||
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok()
|
||||
// Audio files are content-addressed and may be shared between songs, so an ogg is
|
||||
// only unlinkable once no other song's play/select cue names it. The LIKE scan finds
|
||||
// candidate rows cheaply and the parsed cues confirm the hit (a substring coincidence
|
||||
// elsewhere in the blob is not a reference), exactly like custom_3dmv::blob_in_use.
|
||||
//
|
||||
// Returns Result and NEVER folds an error into "false": the caller unlinks on false,
|
||||
// and a read that failed (SQLITE_BUSY under a concurrent write, a corrupt page) says
|
||||
// nothing about whether the file is referenced. The startup sweep is deliberately
|
||||
// fail-closed for the same reason; this is the online half of that rule
|
||||
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> Result<bool, rusqlite::Error> {
|
||||
let rows = DATABASE.lock_and_select_all(
|
||||
"SELECT song FROM songs WHERE music_id!=?1 AND song LIKE ?2",
|
||||
params!(ignored_music_id, format!("%{}%", md5))
|
||||
)?;
|
||||
for blob in rows.members() {
|
||||
let Ok(song) = jzon::parse(&blob.to_string()) else {
|
||||
// An unparseable row could reference anything - assume it does
|
||||
return Ok(true);
|
||||
};
|
||||
for key in ["play", "select"] {
|
||||
if song["sound"][key]["md5"].as_str() == Some(md5) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Resolve a content-addressed chart/jacket md5 to the per-song-id file that
|
||||
@@ -340,3 +456,10 @@ pub fn find_asset_by_md5(md5: &str) -> Option<(i64, String)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// The on-disk database file, so a test can force the read errors the fail-closed
|
||||
// GC paths are built for
|
||||
#[cfg(test)]
|
||||
pub fn test_db_path() -> String {
|
||||
DATABASE.get_path().to_string()
|
||||
}
|
||||
|
||||
@@ -9,38 +9,36 @@ lazy_static! {
|
||||
static ref DATABASE: SQLite = SQLite::new("permissions.db", setup_tables);
|
||||
}
|
||||
|
||||
// Scopes are flat dotted strings and imply everything below them: holding
|
||||
// "card" grants "card.upload", and "*" grants everything. That hierarchy is
|
||||
// the only notion of "level" - there is no rank integer, so a new capability
|
||||
// is one line here and never a renumbering of anything else.
|
||||
//
|
||||
// Every call site must pass one of these consts, never a literal: an
|
||||
// unrecognised scope string can only ever fail closed in has(), but grant()
|
||||
// rejects it outright so a typo can't be persisted either.
|
||||
pub const ALL: &str = "*";
|
||||
|
||||
pub const CARD: &str = "card";
|
||||
// Create custom cards/characters, and edit or delete your OWN uploads
|
||||
pub const CARD_UPLOAD: &str = "card.upload";
|
||||
// Publish/unpublish and mark obtainable, on your OWN uploads
|
||||
pub const CARD_PUBLISH: &str = "card.publish";
|
||||
// Moderation: edit, delete, publish or unpublish ANYBODY's cards
|
||||
pub const CARD_EDIT: &str = "card.edit";
|
||||
pub const GROUP_MANAGE: &str = "group.manage";
|
||||
|
||||
pub const PERMISSION: &str = "permission";
|
||||
pub const PERMISSION_GRANT: &str = "permission.grant";
|
||||
pub const PERMISSION_REVOKE: &str = "permission.revoke";
|
||||
|
||||
// The whole grantable vocabulary, subtree roots included. Anything not in here
|
||||
// cannot be written to the table
|
||||
pub const ANNOUNCEMENT: &str = "announcement";
|
||||
pub const ANNOUNCEMENT_MANAGE: &str = "announcement.manage";
|
||||
|
||||
// Uploading/publishing your own MVs needs no scope (like custom songs);
|
||||
// 3dmv.edit is moderation over anybody's
|
||||
pub const MV: &str = "3dmv";
|
||||
pub const MV_EDIT: &str = "3dmv.edit";
|
||||
|
||||
pub const SCOPES: &[&str] = &[
|
||||
ALL,
|
||||
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
|
||||
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE
|
||||
GROUP_MANAGE,
|
||||
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
|
||||
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
|
||||
MV, MV_EDIT
|
||||
];
|
||||
|
||||
// Grants live in their own database rather than in userdata.db so that an
|
||||
// account purge can never take administrative state with it
|
||||
|
||||
fn setup_tables(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS grants (
|
||||
@@ -53,18 +51,11 @@ CREATE TABLE IF NOT EXISTS grants (
|
||||
").unwrap();
|
||||
}
|
||||
|
||||
// The owners are a process-level flag (--owner) rather than table rows: they
|
||||
// are the bootstrap grantors, so they have to work on a fresh install with an
|
||||
// empty (or hand-deleted) permissions.db, and they must not be revocable
|
||||
// through the webui
|
||||
fn is_owner(user_id: i64) -> bool {
|
||||
user_id > 0 && crate::runtime::get_owners().contains(&user_id)
|
||||
}
|
||||
|
||||
// Every scope that would satisfy a request for `scope`: "*", each dotted
|
||||
// ancestor, and the scope itself. Matching is on whole dot-separated segments,
|
||||
// so "car" never satisfies "card.upload"
|
||||
fn implied_by(scope: &str) -> Vec<String> {
|
||||
fn has_permission(scope: &str) -> Vec<String> {
|
||||
if scope == ALL {
|
||||
return vec![String::from(ALL)];
|
||||
}
|
||||
@@ -80,7 +71,7 @@ fn implied_by(scope: &str) -> Vec<String> {
|
||||
rv
|
||||
}
|
||||
|
||||
fn held_scopes(user_id: i64) -> Vec<String> {
|
||||
fn get_permissions(user_id: i64) -> Vec<String> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT scope FROM grants WHERE user_id=?1 ORDER BY scope", params!(user_id)).unwrap_or(array![]);
|
||||
rows.members().map(|scope| scope.to_string()).collect()
|
||||
}
|
||||
@@ -100,13 +91,11 @@ pub fn has(user_id: i64, scope: &str) -> bool {
|
||||
if is_owner(user_id) {
|
||||
return true;
|
||||
}
|
||||
let held = held_scopes(user_id);
|
||||
implied_by(scope).iter().any(|candidate| held.contains(candidate))
|
||||
let held = get_permissions(user_id);
|
||||
has_permission(scope).iter().any(|candidate| held.contains(candidate))
|
||||
}
|
||||
|
||||
// Everything this user holds, for the webui to hide what it can't use. An
|
||||
// owner's implicit "*" is reported here even though it has no row
|
||||
pub fn scopes_for(user_id: i64) -> JsonValue {
|
||||
pub fn get_user_permissions(user_id: i64) -> JsonValue {
|
||||
if user_id <= 0 {
|
||||
return array![];
|
||||
}
|
||||
@@ -114,7 +103,7 @@ pub fn scopes_for(user_id: i64) -> JsonValue {
|
||||
if is_owner(user_id) {
|
||||
scopes.push(String::from(ALL));
|
||||
}
|
||||
for scope in held_scopes(user_id) {
|
||||
for scope in get_permissions(user_id) {
|
||||
if !scopes.contains(&scope) {
|
||||
scopes.push(scope);
|
||||
}
|
||||
@@ -148,16 +137,6 @@ pub fn grants() -> JsonValue {
|
||||
rv
|
||||
}
|
||||
|
||||
// The only way a row is ever written. Two conditions, both required:
|
||||
//
|
||||
// 1. the grantor holds permission.grant, and
|
||||
// 2. the grantor holds the scope being granted
|
||||
//
|
||||
// (2) is what makes escalation impossible. has() only ever implies downwards,
|
||||
// so holding a leaf never satisfies its parent - a user with card.upload can
|
||||
// hand out card.upload and nothing else, and can no more grant themselves
|
||||
// "card" (or "*") than they can grant it to anybody else. Both checks read the
|
||||
// live table, so a revoked grantor loses the ability on their next request
|
||||
pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
|
||||
if user_id <= 0 {
|
||||
return Err(String::from("Invalid user id"));
|
||||
@@ -175,9 +154,6 @@ pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Revoking needs the same "you must hold it yourself" rule as granting, so a
|
||||
// junior admin can't strip a senior one. An owner has no rows to delete, but
|
||||
// the check is explicit so it stays true if that ever changes
|
||||
pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> {
|
||||
if user_id <= 0 {
|
||||
return Err(String::from("Invalid user id"));
|
||||
@@ -198,6 +174,11 @@ pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
// rest of file is tests that ai wrote
|
||||
// I didn't read through them because I don't super care about tests but they probably do something
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -258,7 +239,7 @@ mod tests {
|
||||
for scope in SCOPES {
|
||||
assert!(!has(105, scope), "scope {}", scope);
|
||||
}
|
||||
assert!(scopes_for(105).is_empty());
|
||||
assert!(get_user_permissions(105).is_empty());
|
||||
assert!(!has(0, ALL));
|
||||
assert!(!has(-1, ALL));
|
||||
wipe(105);
|
||||
@@ -276,7 +257,7 @@ mod tests {
|
||||
insert(110, CARD_UPLOAD, 0);
|
||||
grant(111, CARD_UPLOAD, 110).unwrap();
|
||||
grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent
|
||||
assert_eq!(held_scopes(111), vec![String::from(CARD_UPLOAD)]);
|
||||
assert_eq!(get_permissions(111), vec![String::from(CARD_UPLOAD)]);
|
||||
assert!(grant(111, CARD_EDIT, 110).is_err());
|
||||
assert!(grant(111, CARD, 110).is_err());
|
||||
assert!(grant(110, ALL, 110).is_err());
|
||||
@@ -324,9 +305,9 @@ mod tests {
|
||||
assert!(has(118, scope), "scope {}", scope);
|
||||
assert!(has(120, scope), "scope {}", scope);
|
||||
}
|
||||
assert_eq!(scopes_for(118).len(), 1);
|
||||
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL));
|
||||
assert!(held_scopes(118).is_empty());
|
||||
assert_eq!(get_user_permissions(118).len(), 1);
|
||||
assert_eq!(get_user_permissions(118)[0].to_string(), String::from(ALL));
|
||||
assert!(get_permissions(118).is_empty());
|
||||
// An owner can bootstrap-grant, and can't be revoked
|
||||
grant(119, ALL, 118).unwrap();
|
||||
assert!(has(119, ALL));
|
||||
@@ -346,7 +327,7 @@ mod tests {
|
||||
assert!(!scope.is_empty());
|
||||
assert!(!scope.ends_with('.'));
|
||||
}
|
||||
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE] {
|
||||
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE, MV_EDIT] {
|
||||
assert!(SCOPES.contains(&scope), "scope {}", scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod static_handlers;
|
||||
mod maintenance;
|
||||
mod options;
|
||||
mod router;
|
||||
mod encryption;
|
||||
@@ -19,8 +20,9 @@ mod ios;
|
||||
use actix_web::{rt, App, HttpServer, web, dev::Service};
|
||||
//use actix_cors::Cors;
|
||||
use std::time::Duration;
|
||||
pub use options::get_args;
|
||||
pub use options::{get_args, Commands};
|
||||
use runtime::get_data_path;
|
||||
pub use maintenance::run as run_maintenance;
|
||||
|
||||
#[actix_web::main]
|
||||
pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
||||
@@ -29,17 +31,18 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
||||
|
||||
runtime::update_owners(&args.owner);
|
||||
|
||||
runtime::set_nerf_custom_cards(args.nerf_custom_cards);
|
||||
|
||||
if args.purge {
|
||||
println!("Purging accounts...");
|
||||
let machines = crate::router::arcade::purge_machines();
|
||||
println!("Purged {} arcade machines", machines);
|
||||
let ct = crate::router::userdata::purge_accounts();
|
||||
println!("Purged {} accounts", ct);
|
||||
}
|
||||
|
||||
router::custom_song::migrate::run();
|
||||
router::custom_song::sweep_audio();
|
||||
// The multi-live relay's expiry timers, on the system arbiter rather than on whichever
|
||||
// HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not
|
||||
// be able to strand every room's seats for the life of the process.
|
||||
router::custom_3dmv::sweep_blobs();
|
||||
router::multi_live::start_sweeper();
|
||||
|
||||
let rv = HttpServer::new(|| App::new()
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#[cfg(not(feature = "library"))]
|
||||
fn main() -> std::io::Result<()> {
|
||||
let args = ew::get_args();
|
||||
if let Some(ew::Commands::Maintenance { message }) = args.command {
|
||||
return ew::run_maintenance(args.port, message);
|
||||
}
|
||||
ew::runtime::update_data_path(&args.path);
|
||||
ew::runtime::update_masterdata_path(&args.masterdata);
|
||||
ew::run_server(false)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use jzon::object;
|
||||
|
||||
#[actix_web::main]
|
||||
pub async fn run(port: u16, message: String) -> std::io::Result<()> {
|
||||
let message = web::Data::new(message);
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(message.clone())
|
||||
.default_service(web::route().to(respond))
|
||||
})
|
||||
.bind(("0.0.0.0", port))?
|
||||
.run();
|
||||
println!("Maintenance server listening on http://0.0.0.0:{port}");
|
||||
server.await
|
||||
}
|
||||
|
||||
async fn respond(req: HttpRequest, message: web::Data<String>) -> HttpResponse {
|
||||
let message = message.get_ref().as_str();
|
||||
if req.path() == "/maintenance/maintenance.json" {
|
||||
// The title screen requires HTTP 200, server=false, and an active UTC window.
|
||||
return HttpResponse::Ok()
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.content_type("application/json")
|
||||
.body(object! {
|
||||
"opened_at": "1970-01-01 00:00:00",
|
||||
"closed_at": "9999-01-01 00:00:00",
|
||||
"message": message,
|
||||
"server": false,
|
||||
"gamelib": 0
|
||||
}.dump());
|
||||
}
|
||||
|
||||
let webui = req.path() == "/api/webui" || req.path().starts_with("/api/webui/");
|
||||
let game_api = req.path() == "/api" || req.path().starts_with("/api/");
|
||||
let browser = req.headers().get("Accept").and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("text/html"));
|
||||
if req.headers().contains_key("aoharu-asset-version") || (game_api && !webui && !browser) {
|
||||
// Do not use global::send: its per-user clock can access the database.
|
||||
let body = object! {
|
||||
"code": 10,
|
||||
"server_time": crate::router::global::timestamp(),
|
||||
"message": message
|
||||
}.dump();
|
||||
return HttpResponse::Ok()
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.body(crate::encryption::encrypt_packet(&body).expect("packet encryption failed"));
|
||||
}
|
||||
|
||||
let escaped = message.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
.replace('"', """).replace('\'', "'");
|
||||
HttpResponse::ServiceUnavailable()
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(include_str!("../web_assets/maintenance.html").replace("{{message}}", &escaped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_web::{http::{Method, StatusCode}, test};
|
||||
|
||||
const MESSAGE: &str = "Updating \"songs\" & <cards> — またね\nPlease wait.";
|
||||
|
||||
#[actix_web::test]
|
||||
async fn title_check_is_active_and_preserves_message() {
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
|
||||
.default_service(web::route().to(respond))).await;
|
||||
let req = test::TestRequest::get().uri("/maintenance/maintenance.json?cache=1").to_request();
|
||||
let response = test::call_service(&app, req).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store");
|
||||
let body = test::read_body(response).await;
|
||||
let json = jzon::parse(std::str::from_utf8(&body).unwrap()).unwrap();
|
||||
assert_eq!(json["message"].as_str(), Some(MESSAGE));
|
||||
assert_eq!(json["server"].as_bool(), Some(false));
|
||||
let now = crate::router::global::format_datetime(crate::router::global::timestamp());
|
||||
assert!(json["opened_at"].as_str().unwrap() < now.as_str());
|
||||
assert!(json["closed_at"].as_str().unwrap() > now.as_str());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn game_requests_get_encrypted_maintenance_without_reading_the_body() {
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
|
||||
.default_service(web::route().to(respond))).await;
|
||||
for (path, header) in [("/api/start", false), ("/api/live/end", true),
|
||||
("/api/unknown", false), ("/v1.0/test", true), ("/anything", true)] {
|
||||
let mut req = test::TestRequest::post().uri(path)
|
||||
.insert_header(("aoharu-user-id", "12345")).set_payload("not an encrypted request");
|
||||
if header { req = req.insert_header(("aoharu-asset-version", "old")); }
|
||||
let response = test::call_service(&app, req.to_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||
let body = test::read_body(response).await;
|
||||
let decoded = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
|
||||
let json = jzon::parse(&decoded).unwrap();
|
||||
assert_eq!(json["code"].as_i32(), Some(10));
|
||||
assert_eq!(json["message"].as_str(), Some(MESSAGE));
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn all_other_routes_and_methods_are_maintenance_only() {
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
|
||||
.default_service(web::route().to(respond))).await;
|
||||
for path in ["/", "/maintenance.html", "/custom_song/upload", "/custom_card/delete",
|
||||
"/custom_3dmv/upload", "/announcement/create", "/api/webui/login",
|
||||
"/Android/hash/file", "/v1.0/test", "/api/user", "/unknown"] {
|
||||
for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] {
|
||||
let response = test::call_service(&app,
|
||||
test::TestRequest::default().method(method).uri(path)
|
||||
.insert_header(("Accept", "text/html")).to_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
|
||||
let body = test::read_body(response).await;
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
assert!(html.contains("<cards>"));
|
||||
assert!(html.contains("またね"));
|
||||
assert!(!html.contains("<cards>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[arg(short, long, default_value_t = 8080, help = "Port to listen on")]
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
#[arg(short, long, global = true, default_value_t = 8080, help = "Port to listen on")]
|
||||
pub port: u16,
|
||||
|
||||
#[arg(long, default_value = "./", help = "Path to store database files")]
|
||||
@@ -44,6 +47,21 @@ pub struct Args {
|
||||
#[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")]
|
||||
pub enable_custom_cards: bool,
|
||||
|
||||
#[arg(long, default_value_t = false, help = "Nerf custom cards: cap base stats below the official per-rarity maxima (median band) and skill values at 80% of the official range. Applies at upload time and to cards already uploaded, which are clamped in every response")]
|
||||
pub nerf_custom_cards: bool,
|
||||
|
||||
#[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
|
||||
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>,
|
||||
|
||||
@@ -71,9 +89,21 @@ pub struct Args {
|
||||
#[arg(long, default_value = "", help = "Asset version for overridden asset hashes.")]
|
||||
pub asset_version: String,
|
||||
|
||||
#[arg(long, default_value = "", help = "Global asset version for overridden asset hashes.")]
|
||||
pub en_asset_version: String,
|
||||
|
||||
#[arg(long, default_value_t = false, help = "Force an application update for asset versions not marked as latest")]
|
||||
pub force_updates: bool,
|
||||
|
||||
#[arg(long, default_value = "", help = "Asset hash for windows client.")]
|
||||
pub windows_asset_hash: String,
|
||||
|
||||
#[arg(long, default_value = "", help = "Asset hash for linux client.")]
|
||||
pub linux_asset_hash: String,
|
||||
|
||||
#[arg(long, default_value = "", help = "Asset hash for macOS client.")]
|
||||
pub macos_asset_hash: String,
|
||||
|
||||
#[arg(long, default_value = "", help = "Path to image assets.")]
|
||||
pub image_asset_path: String,
|
||||
|
||||
@@ -81,8 +111,41 @@ pub struct Args {
|
||||
pub masterdata: String
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Start a maintenance-only server until stopped
|
||||
Maintenance {
|
||||
/// The message shown to the client
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn get_args() -> Args {
|
||||
#[cfg(not(test))]
|
||||
let mut args = Args::parse();
|
||||
#[cfg(test)]
|
||||
let mut args = Args::parse_from(["ew"]);
|
||||
crate::runtime::overlay_args(&mut args);
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maintenance_command_and_port() {
|
||||
for argv in [
|
||||
vec!["ew", "--port", "6017", "maintenance", "Updating songs"],
|
||||
vec!["ew", "maintenance", "Updating songs", "--port", "6017"],
|
||||
] {
|
||||
let args = Args::try_parse_from(argv).unwrap();
|
||||
assert_eq!(args.port, 6017);
|
||||
assert!(matches!(args.command, Some(Commands::Maintenance { message }) if message == "Updating songs"));
|
||||
}
|
||||
assert!(Args::try_parse_from(["ew", "maintenance"]).is_err());
|
||||
let normal = Args::try_parse_from(["ew"]).unwrap();
|
||||
assert!(normal.command.is_none());
|
||||
assert_eq!(normal.port, 8080);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod start;
|
||||
pub mod arcade;
|
||||
pub mod global;
|
||||
pub mod login;
|
||||
pub mod userdata;
|
||||
@@ -6,6 +7,7 @@ pub mod user;
|
||||
pub mod purchase;
|
||||
pub mod tutorial;
|
||||
pub mod mission;
|
||||
pub mod beginner_mission;
|
||||
pub mod home;
|
||||
pub mod lottery;
|
||||
pub mod friend;
|
||||
@@ -23,6 +25,8 @@ pub mod card;
|
||||
pub mod shop;
|
||||
pub mod custom_song;
|
||||
pub mod custom_card;
|
||||
pub mod custom_group;
|
||||
pub mod custom_3dmv;
|
||||
pub mod rich_text;
|
||||
pub mod webui;
|
||||
pub mod clear_rate;
|
||||
@@ -188,7 +192,7 @@ fn not_found(req: &HttpRequest) -> HttpResponse {
|
||||
global::send(rv, 0, req)
|
||||
}
|
||||
|
||||
// Fallback for paths no actix route matched. Game endpoints live in each module's routes()
|
||||
// Fallback
|
||||
async fn api_req(req: HttpRequest, body: String) -> HttpResponse {
|
||||
let args = crate::get_args();
|
||||
if args.hidden && (req.path().starts_with("/api/webui/") || !(req.path().starts_with("/api") || req.path().starts_with("/v1.0"))) {
|
||||
@@ -222,11 +226,13 @@ 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/linkNesicaCard" => webui::link_nesica_card(req, body),
|
||||
"/api/webui/unlinkNesicaCard" => webui::unlink_nesica_card(req, body),
|
||||
_ => api_req(req, body).await
|
||||
}
|
||||
} else {
|
||||
match req.path() {
|
||||
"/web/announcement" => web::announcement(req),
|
||||
"/api/webui/userInfo" => webui::user(req),
|
||||
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
|
||||
"/webui/logout" => webui::logout(req),
|
||||
@@ -240,7 +246,10 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
|
||||
"/api/webui/listCharacters" => webui::list_characters(req),
|
||||
"/api/webui/listSkillCenters" => webui::list_skill_centers(req),
|
||||
"/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/webui/listNesicaCards" => webui::list_nesica_cards(req),
|
||||
_ => api_req(req, body).await
|
||||
}
|
||||
}
|
||||
@@ -260,10 +269,12 @@ 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)
|
||||
.configure(custom_card::routes)
|
||||
.configure(custom_3dmv::routes)
|
||||
.configure(debug::routes)
|
||||
.configure(event::routes)
|
||||
.configure(exchange::routes)
|
||||
@@ -292,4 +303,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
);
|
||||
cfg.configure(custom_song::web_routes);
|
||||
cfg.configure(custom_card::web_routes);
|
||||
cfg.configure(custom_group::web_routes);
|
||||
cfg.configure(custom_3dmv::web_routes);
|
||||
cfg.configure(web::routes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
// Arcade mode: a SIF2 client turned into a rhythm cabinet.
|
||||
|
||||
use jzon::{object, JsonValue};
|
||||
use actix_web::{web, Responder};
|
||||
|
||||
use crate::router::{databases, global, live, multi_live, userdata, Api, Body};
|
||||
use crate::router::userdata::user::migration;
|
||||
use crate::database::arcade as database;
|
||||
|
||||
const DEFAULT_MACHINE_NAME: &str = "ARCADE";
|
||||
const GUEST_NAME: &str = "GUEST";
|
||||
|
||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/arcade")
|
||||
.route("/info", web::get().to(info))
|
||||
.route("/register", web::post().to(register))
|
||||
.route("/session", web::post().to(session))
|
||||
.route("/bind", web::post().to(bind))
|
||||
);
|
||||
}
|
||||
|
||||
pub fn disabled() -> bool {
|
||||
let args = crate::get_args();
|
||||
args.hidden || !args.enable_arcade
|
||||
}
|
||||
|
||||
// 0 = forever
|
||||
fn machine_ttl_days() -> u64 {
|
||||
crate::get_args().arcade_machine_ttl
|
||||
}
|
||||
|
||||
fn session_ttl() -> i64 {
|
||||
crate::get_args().arcade_session_ttl as i64 * 60
|
||||
}
|
||||
|
||||
const MAX_SESSION_WINDOWS: i64 = 4;
|
||||
|
||||
fn flag(value: &JsonValue) -> bool {
|
||||
value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0
|
||||
}
|
||||
|
||||
fn card_id(body: &JsonValue) -> Option<String> {
|
||||
migration::valid_card_id(body["card_id"].as_str().unwrap_or(""))
|
||||
}
|
||||
|
||||
pub fn is_cabinet_account(user_id: i64) -> bool {
|
||||
!disabled() && database::machine_of_account(user_id).is_some()
|
||||
}
|
||||
|
||||
pub fn card_relinked(card: &str) {
|
||||
if !disabled() {
|
||||
database::clear_card_session(card);
|
||||
}
|
||||
}
|
||||
|
||||
fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> {
|
||||
database::live_card_session(&migration::cards_of_account(user_id), now)
|
||||
}
|
||||
|
||||
fn open_card_session(card: &str, machine_id: &str) {
|
||||
database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl());
|
||||
}
|
||||
|
||||
async fn info() -> impl Responder {
|
||||
if disabled() {
|
||||
return Api(None);
|
||||
}
|
||||
Api(Some(object!{
|
||||
"enabled": true,
|
||||
"machine_ttl_days": machine_ttl_days()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn register(Body(body): Body) -> impl Responder {
|
||||
if disabled() {
|
||||
return Api(None);
|
||||
}
|
||||
let name = userdata::starter::clean_name(body["name"].as_str().unwrap_or(""), DEFAULT_MACHINE_NAME);
|
||||
|
||||
let Some((machine_user_id, machine_uuid)) = userdata::starter::create(&name) else {
|
||||
return Api(None);
|
||||
};
|
||||
let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else {
|
||||
userdata::delete_account(machine_user_id);
|
||||
return Api(None);
|
||||
};
|
||||
|
||||
let machine_id = database::generate_machine_id();
|
||||
database::insert_machine(&machine_id, &name, machine_user_id, guest_user_id);
|
||||
println!("arcade: registered machine {} \"{}\" (machine {}, guest {})", machine_id, name, machine_user_id, guest_user_id);
|
||||
|
||||
Api(Some(object!{
|
||||
"machine_id": machine_id,
|
||||
"machine_user_id": machine_user_id,
|
||||
"machine_uuid": machine_uuid,
|
||||
"guest_user_id": guest_user_id,
|
||||
"guest_uuid": guest_uuid
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_card(card: &str) -> Option<(i64, String)> {
|
||||
let user_id = migration::card_user(card)?;
|
||||
let uuid = userdata::get_login_token(user_id);
|
||||
if uuid.is_empty() {
|
||||
println!("arcade: card mapping pointed at missing account {} - unlinking the card", user_id);
|
||||
migration::remove_card(card);
|
||||
return None;
|
||||
}
|
||||
Some((user_id, uuid))
|
||||
}
|
||||
|
||||
async fn session(Body(body): Body) -> impl Responder {
|
||||
if disabled() {
|
||||
return Api(None);
|
||||
}
|
||||
let machine_id = body["machine_id"].as_str().unwrap_or("").to_string();
|
||||
let Some(machine) = database::get_machine(&machine_id) else {
|
||||
println!("arcade: session for unknown machine \"{}\"", machine_id);
|
||||
return Api(None);
|
||||
};
|
||||
database::touch_machine(&machine_id);
|
||||
let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string();
|
||||
|
||||
let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty();
|
||||
let card = card_id(&body);
|
||||
if presented && card.is_none() {
|
||||
println!("arcade: machine {} presented an unusable card id", machine_id);
|
||||
return Api(None);
|
||||
}
|
||||
|
||||
let Some(card) = card else {
|
||||
let guest_user_id = machine["guest_user_id"].as_i64().unwrap_or(0);
|
||||
let Some(uuid) = userdata::starter::reset(guest_user_id, &machine_name) else {
|
||||
println!("arcade: machine {} has no guest account to reset", machine_id);
|
||||
return Api(None);
|
||||
};
|
||||
return Api(Some(object!{
|
||||
"user_id": guest_user_id,
|
||||
"uuid": uuid,
|
||||
"name": machine_name,
|
||||
"unlinked": false
|
||||
}));
|
||||
};
|
||||
|
||||
// A known card plays its own account, with every clear it has ever earned
|
||||
let Some((user_id, uuid)) = resolve_card(&card) else {
|
||||
println!("arcade: machine {} presented a card that is linked to no account", machine_id);
|
||||
return Api(Some(object!{ "unlinked": true }));
|
||||
};
|
||||
open_card_session(&card, &machine_id);
|
||||
let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string();
|
||||
Api(Some(object!{
|
||||
"user_id": user_id,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"unlinked": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> {
|
||||
if disabled() {
|
||||
return Err(String::from("Arcade mode is disabled on this server"));
|
||||
}
|
||||
migration::link_card(card, user_id)?;
|
||||
println!("arcade: card {} now plays as account {}", card, user_id);
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> {
|
||||
if disabled() {
|
||||
return Err(String::from("Arcade mode is disabled on this server"));
|
||||
}
|
||||
let account = userdata::user::migration::get_acc_transfer(migration_code, pass);
|
||||
if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 {
|
||||
return Err(String::from("Transfer code and password don't match"));
|
||||
}
|
||||
let Some(user_id) = account["user_id"].as_i64() else {
|
||||
return Err(String::from("Transfer code and password don't match"));
|
||||
};
|
||||
bind_card_to(card, user_id)
|
||||
}
|
||||
|
||||
async fn bind(Body(body): Body) -> impl Responder {
|
||||
match bind_card(
|
||||
body["card_id"].as_str().unwrap_or(""),
|
||||
&body["migrationCode"].to_string(),
|
||||
&body["pass"].to_string()
|
||||
) {
|
||||
Ok(user_id) => Api(Some(object!{ "user_id": user_id })),
|
||||
Err(reason) => {
|
||||
println!("arcade: bind refused - {}", reason);
|
||||
Api(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lives
|
||||
|
||||
fn cabinet_account_at(login_token: &str, now: i64) -> Option<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);
|
||||
}
|
||||
live_card_session(user_id, now).map(|_| user_id)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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_cards(&migration::cards_of_account(user_id))
|
||||
}
|
||||
|
||||
pub fn live_started(login_token: &str, body: &JsonValue) {
|
||||
let now = global::timestamp() as i64;
|
||||
let Some(user_id) = arcade_account_at(login_token, body, now) else { return; };
|
||||
if let Some(machine_id) = play_machine(user_id) {
|
||||
database::touch_machine(&machine_id);
|
||||
}
|
||||
if let Some((card, opened)) = live_card_session(user_id, now) {
|
||||
let ttl = session_ttl();
|
||||
database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn live_end_body(body: &JsonValue) -> JsonValue {
|
||||
let mut rv = body.clone();
|
||||
rv["use_lp"] = multi_live::boost_lp(1).into();
|
||||
rv
|
||||
}
|
||||
|
||||
fn score_rank(live_id: i64, score: i64) -> i64 {
|
||||
let live = &databases::LIVE_LIST[live_id.to_string()];
|
||||
let mut rank = 0;
|
||||
for (index, key) in ["scoreC", "scoreB", "scoreA", "scoreS"].iter().enumerate() {
|
||||
if let Some(threshold) = live[*key].as_i64()
|
||||
&& threshold > 0
|
||||
&& score >= threshold {
|
||||
rank = index as i64 + 1;
|
||||
}
|
||||
}
|
||||
rank
|
||||
}
|
||||
|
||||
pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option<i64> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) {
|
||||
let Some(machine_id) = play_machine(user_id) else { return; };
|
||||
let live_id = body["master_live_id"].as_i64().unwrap_or(0);
|
||||
let level = body["level"].as_i64().unwrap_or(0);
|
||||
let score = body["live_score"]["score"].as_i64().unwrap_or(0);
|
||||
database::insert_play(&machine_id, user_id, live_id, level, score, score_rank(live_id, score), cleared);
|
||||
database::touch_machine(&machine_id);
|
||||
}
|
||||
|
||||
// maintenance
|
||||
|
||||
pub fn purge_machines() -> usize {
|
||||
if disabled() {
|
||||
return 0;
|
||||
}
|
||||
let ttl = machine_ttl_days();
|
||||
// 0 = never
|
||||
if ttl == 0 {
|
||||
return 0;
|
||||
}
|
||||
purge_machines_before(global::timestamp() as i64 - (ttl as i64 * 24 * 60 * 60))
|
||||
}
|
||||
|
||||
fn purge_machines_before(cutoff: i64) -> usize {
|
||||
let dead = database::machines_last_seen_before(cutoff);
|
||||
for machine in dead.members() {
|
||||
let machine_id = machine["machine_id"].as_str().unwrap_or("");
|
||||
println!(
|
||||
"Removing arcade machine {} (last seen {})",
|
||||
machine_id,
|
||||
global::format_datetime(machine["last_seen"].as_u64().unwrap_or(0))
|
||||
);
|
||||
for key in ["machine_user_id", "guest_user_id"] {
|
||||
let user_id = machine[key].as_i64().unwrap_or(0);
|
||||
if user_id != 0 && !migration::account_has_card(user_id) {
|
||||
userdata::delete_account(user_id);
|
||||
}
|
||||
}
|
||||
database::delete_machine(machine_id);
|
||||
}
|
||||
dead.len()
|
||||
}
|
||||
|
||||
// webui
|
||||
|
||||
pub fn webui_machines() -> JsonValue {
|
||||
if disabled() {
|
||||
return jzon::array![];
|
||||
}
|
||||
database::list_machines()
|
||||
}
|
||||
|
||||
pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> {
|
||||
if disabled() {
|
||||
return Err(String::from("Arcade mode is disabled on this server"));
|
||||
}
|
||||
let Some(machine) = database::get_machine(machine_id) else {
|
||||
return Err(format!("No arcade machine {}", machine_id));
|
||||
};
|
||||
for key in ["machine_user_id", "guest_user_id"] {
|
||||
let user_id = machine[key].as_i64().unwrap_or(0);
|
||||
if user_id != 0 && !migration::account_has_card(user_id) {
|
||||
userdata::delete_account(user_id);
|
||||
}
|
||||
}
|
||||
database::delete_machine(machine_id);
|
||||
println!("arcade: machine {} removed through the webui", machine_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// Rest of file is tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The flag the client sends as 0/1 and the flag it might send as a bool are
|
||||
// the same flag; nothing else is
|
||||
#[test]
|
||||
fn the_arcade_flag_reads_both_shapes() {
|
||||
assert!(flag(&true.into()));
|
||||
assert!(flag(&(1).into()));
|
||||
assert!(!flag(&false.into()));
|
||||
assert!(!flag(&(0).into()));
|
||||
assert!(!flag(&JsonValue::Null));
|
||||
assert!(!flag(&"yes".into()));
|
||||
}
|
||||
|
||||
// A card id is refused rather than repaired
|
||||
#[test]
|
||||
fn card_ids_are_validated_not_sanitised() {
|
||||
assert_eq!(card_id(&object!{ card_id: "0123456789012345" }).as_deref(), Some("0123456789012345"));
|
||||
assert_eq!(card_id(&object!{ card_id: " 0123456789012345 " }).as_deref(), Some("0123456789012345"));
|
||||
assert!(card_id(&object!{}).is_none());
|
||||
assert!(card_id(&object!{ card_id: "" }).is_none());
|
||||
assert!(card_id(&object!{ card_id: "0123-4567" }).is_none());
|
||||
assert!(card_id(&object!{ card_id: "'; DROP TABLE cards--" }).is_none());
|
||||
}
|
||||
|
||||
// The rank stored in the ledger is the one the result screen shows, read off
|
||||
// the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000,
|
||||
// A 250000, S 350000)
|
||||
#[test]
|
||||
fn the_ledger_rank_comes_from_the_lives_own_thresholds() {
|
||||
assert_eq!(score_rank(1100101, 0), 0);
|
||||
assert_eq!(score_rank(1100101, 19_999), 0);
|
||||
assert_eq!(score_rank(1100101, 20_000), 1);
|
||||
assert_eq!(score_rank(1100101, 100_000), 2);
|
||||
assert_eq!(score_rank(1100101, 250_000), 3);
|
||||
assert_eq!(score_rank(1100101, 350_000), 4);
|
||||
assert_eq!(score_rank(1100101, 9_999_999), 4);
|
||||
// A custom song has no official row, so it has no rank
|
||||
assert_eq!(score_rank(10_000, 9_999_999), 0);
|
||||
}
|
||||
|
||||
// A card resolves to the account it names, and to nothing at all once that
|
||||
// account is gone - and the dead mapping does not survive the answer
|
||||
#[test]
|
||||
fn a_card_resolves_to_its_account_and_a_dead_mapping_is_dropped() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
use crate::database::arcade as db;
|
||||
|
||||
// Nobody linked it: unlinked, and nothing was created for it
|
||||
assert!(resolve_card("7020392000000001").is_none());
|
||||
assert!(migration::card_user("7020392000000001").is_none());
|
||||
|
||||
// Linked: the account and its token
|
||||
let (player, token) = userdata::starter::create("Linked").unwrap();
|
||||
migration::set_card("7020392000000002", player);
|
||||
assert_eq!(resolve_card("7020392000000002"), Some((player, token)));
|
||||
|
||||
// The account was deleted: unlinked from now on, mapping gone
|
||||
userdata::delete_account(player);
|
||||
assert!(resolve_card("7020392000000002").is_none());
|
||||
assert!(migration::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived");
|
||||
}
|
||||
|
||||
// bind_card_to is the cabinet's entrance to the shared link rule
|
||||
// (migration::link_card): a cabinet's own identities are refused and a card
|
||||
// that already names an account is re-pointed, never refused
|
||||
#[test]
|
||||
fn bind_card_to_refuses_cabinet_identities_and_repoints_a_linked_card() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
use crate::database::arcade as db;
|
||||
|
||||
let (machine_account, _) = userdata::starter::create("Cabinet Bind").unwrap();
|
||||
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||
let machine_id = db::generate_machine_id();
|
||||
db::insert_machine(&machine_id, "Cabinet Bind", machine_account, guest_account);
|
||||
let (player, _) = userdata::starter::create("Player").unwrap();
|
||||
|
||||
// The id is validated, not repaired
|
||||
assert!(bind_card_to("0123-4567", player).is_err());
|
||||
assert!(migration::card_user("0123-4567").is_none());
|
||||
|
||||
// Neither cabinet identity may sit behind a card
|
||||
let card = "4242424242424242";
|
||||
assert!(bind_card_to(card, machine_account).is_err());
|
||||
assert!(bind_card_to(card, guest_account).is_err());
|
||||
assert!(migration::card_user(card).is_none());
|
||||
|
||||
// A card another player linked is re-pointed, and that player's account
|
||||
// is untouched - the card was the only thing that changed hands
|
||||
let (previous, _) = userdata::starter::create("Previous").unwrap();
|
||||
migration::set_card(card, previous);
|
||||
assert_eq!(bind_card_to(card, player), Ok(player));
|
||||
assert_eq!(migration::card_user(card), Some(player));
|
||||
assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account");
|
||||
|
||||
db::delete_machine(&machine_id);
|
||||
for id in [player, previous, machine_account, guest_account] {
|
||||
userdata::delete_account(id);
|
||||
}
|
||||
}
|
||||
|
||||
// A cabinet's song that ended with an empty life gauge is reported at
|
||||
// /live/retire, which carries no arcade flag - the start record is what
|
||||
// proves it was a cabinet's - and it lands in the ledger as a failed song
|
||||
// rather than not at all.
|
||||
#[test]
|
||||
fn a_failed_cabinet_song_lands_in_the_ledger_uncleared() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
use crate::database::arcade as db;
|
||||
|
||||
let (machine_account, _) = userdata::starter::create("Cabinet Retire").unwrap();
|
||||
let (guest_account, guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||
let machine_id = db::generate_machine_id();
|
||||
db::insert_machine(&machine_id, "Cabinet Retire", machine_account, guest_account);
|
||||
|
||||
// The credit's song: /live/start carried the flag, so the record does
|
||||
let start = object!{
|
||||
master_live_id: 1100101,
|
||||
level: 4,
|
||||
deck_slot: 1,
|
||||
live_boost: 1,
|
||||
arcade: true
|
||||
};
|
||||
live::start_live(&guest_token, &start);
|
||||
|
||||
// The life gauge emptied: the client played it out and reported the score
|
||||
// it reached at /live/retire, which has no arcade flag of its own
|
||||
let retire = object!{
|
||||
master_live_id: 1100101,
|
||||
level: 4,
|
||||
live_score: { score: 123_456, max_combo: 40, play_time: 93 }
|
||||
};
|
||||
let now = global::timestamp() as i64;
|
||||
let user_id = retire_user_at(&guest_token, &retire, now).expect("a cabinet's retire was not recognised");
|
||||
assert_eq!(user_id, guest_account);
|
||||
record_play(user_id, &retire, false);
|
||||
|
||||
let ledger = db::plays_of_machine(&machine_id);
|
||||
assert_eq!(ledger.len(), 1);
|
||||
assert_eq!(ledger[0]["cleared"].as_bool(), Some(false));
|
||||
assert_eq!(ledger[0]["user_id"].as_i64(), Some(guest_account));
|
||||
assert_eq!(ledger[0]["live_id"].as_i64(), Some(1100101));
|
||||
assert_eq!(ledger[0]["level"].as_i64(), Some(4));
|
||||
assert_eq!(ledger[0]["score"].as_i64(), Some(123_456), "the retire's own score was not carried");
|
||||
// It counts as a song of the credit, and only the cleared count excludes it
|
||||
assert!(db::list_machines().members().any(|m|
|
||||
m["machine_id"] == machine_id.as_str() && m["play_count"] == 1 && m["cleared_count"] == 0
|
||||
));
|
||||
|
||||
// A live that was started as an ordinary play is not a cabinet's song,
|
||||
// however the retire that ends it looks
|
||||
live::start_live(&guest_token, &object!{ master_live_id: 1100102, level: 4, deck_slot: 1 });
|
||||
assert!(retire_user_at(&guest_token, &object!{
|
||||
master_live_id: 1100102,
|
||||
level: 4,
|
||||
live_score: { score: 1, max_combo: 1, play_time: 93 }
|
||||
}, now).is_none(), "an unflagged start was bookkept as a cabinet's song");
|
||||
|
||||
// Neither is a retire with no start behind it at all
|
||||
assert!(retire_user_at(&guest_token, &object!{
|
||||
master_live_id: 1100103,
|
||||
level: 4,
|
||||
live_score: { score: 1, max_combo: 1, play_time: 93 }
|
||||
}, now).is_none());
|
||||
|
||||
db::delete_machine(&machine_id);
|
||||
userdata::delete_account(machine_account);
|
||||
userdata::delete_account(guest_account);
|
||||
}
|
||||
|
||||
// A cabinet that aged out takes its two accounts with it - and nothing
|
||||
// else. A cabinet still in use, and any account a player bound a card to,
|
||||
// survives the sweep.
|
||||
#[test]
|
||||
fn an_aged_out_cabinet_takes_only_its_own_accounts() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
use crate::database::arcade as db;
|
||||
|
||||
// Old: unseen since before the cutoff
|
||||
let (old_machine, old_machine_token) = userdata::starter::create("Old cabinet").unwrap();
|
||||
let (old_guest, old_guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||
let old_id = db::generate_machine_id();
|
||||
db::insert_machine(&old_id, "Old cabinet", old_machine, old_guest);
|
||||
|
||||
// Live: seen just now
|
||||
let (live_machine, live_machine_token) = userdata::starter::create("Live cabinet").unwrap();
|
||||
let (live_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||
let live_id = db::generate_machine_id();
|
||||
db::insert_machine(&live_id, "Live cabinet", live_machine, live_guest);
|
||||
|
||||
// Old too, but somebody bound a card to its machine account
|
||||
let (claimed_machine, claimed_machine_token) = userdata::starter::create("Claimed cabinet").unwrap();
|
||||
let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||
let claimed_id = db::generate_machine_id();
|
||||
db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest);
|
||||
migration::set_card("4444333322221111", claimed_machine);
|
||||
|
||||
// A player account with a card, belonging to no cabinet at all
|
||||
let (player, player_token) = userdata::starter::create("Player").unwrap();
|
||||
migration::set_card("8888777766665555", player);
|
||||
|
||||
// Everything registered above is "seen now"; only the two we push back
|
||||
// are older than the cutoff
|
||||
let now = global::timestamp() as i64;
|
||||
for id in [&old_id, &claimed_id] {
|
||||
db::backdate_machine_for_test(id, now - 1000);
|
||||
}
|
||||
|
||||
assert_eq!(purge_machines_before(now - 500), 2);
|
||||
|
||||
// The aged-out cabinet is gone, accounts and all
|
||||
assert!(db::get_machine(&old_id).is_none());
|
||||
assert_eq!(userdata::uid_from_login_token(&old_machine_token), 0);
|
||||
assert_eq!(userdata::uid_from_login_token(&old_guest_token), 0);
|
||||
|
||||
// The cabinet still in use is untouched
|
||||
assert!(db::get_machine(&live_id).is_some());
|
||||
assert_eq!(userdata::uid_from_login_token(&live_machine_token), live_machine);
|
||||
|
||||
// The claimed cabinet is retired, but the account behind its card is not
|
||||
assert!(db::get_machine(&claimed_id).is_none());
|
||||
assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine);
|
||||
assert_eq!(migration::card_user("4444333322221111"), Some(claimed_machine));
|
||||
|
||||
// A card-bound player account is never a candidate in the first place
|
||||
assert_eq!(userdata::uid_from_login_token(&player_token), player);
|
||||
|
||||
db::delete_machine(&live_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use jzon::{array, object, JsonValue};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use super::{databases, items};
|
||||
use databases::csv::{self, Region};
|
||||
|
||||
lazy_static! {
|
||||
static ref CELLS: JsonValue = csv::table(Region::Jp, "beginner_mission");
|
||||
static ref SETTINGS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward_setting");
|
||||
static ref REWARDS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward");
|
||||
static ref CARD_LEVELS: JsonValue = csv::table(Region::Jp, "card_level");
|
||||
static ref SKILL_LEVELS: JsonValue = csv::table(Region::Jp, "card_skill_level");
|
||||
}
|
||||
|
||||
// JP /api/mission + /api/mission/receive captures, 2024-03-31:
|
||||
// all sheets accumulate progress; only reached sheets can be claimed. Line
|
||||
// rewards are milestones in the number of completed lines, not named lines.
|
||||
const LINES: [[i64; 3]; 8] = [
|
||||
[1, 2, 3], [4, 5, 6], [7, 8, 9], [1, 4, 7],
|
||||
[2, 5, 8], [3, 6, 9], [1, 5, 9], [3, 5, 7],
|
||||
];
|
||||
|
||||
pub fn contains(id: i64) -> bool {
|
||||
CELLS.members().any(|c| c["masterMissionId"].as_i64() == Some(id))
|
||||
}
|
||||
|
||||
pub fn level(id: i64) -> i64 {
|
||||
CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id))
|
||||
.and_then(|c| c["level"].as_i64()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_counter(id: i64) -> bool {
|
||||
contains(id) && matches!(databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64(), Some(5 | 6 | 53 | 62 | 63))
|
||||
}
|
||||
|
||||
pub fn ensure(missions: &mut JsonValue) {
|
||||
for cell in CELLS.members() {
|
||||
let id = cell["masterMissionId"].as_i64().unwrap();
|
||||
let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap();
|
||||
if let Some(mission) = missions.members_mut().find(|m| m["master_mission_id"].as_i64() == Some(id)) {
|
||||
// The old login hook kept incrementing an already completed cell.
|
||||
// The client subtracts one to animate an unclaimed completion, so
|
||||
// over-target progress would incorrectly make its "before" sheet clear.
|
||||
let progress = if mission["status"].as_i64().unwrap_or(1) >= 2 { target }
|
||||
else { mission["progress"].as_i64().unwrap_or(0).clamp(0, target) };
|
||||
mission["progress"] = progress.into();
|
||||
continue;
|
||||
}
|
||||
missions.push(object! {
|
||||
master_mission_id: id, status: 1, progress: 0,
|
||||
expire_date_time: 0, not_visible: 0
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_progress(id: i64, progress: i64, missions: &mut JsonValue) -> bool {
|
||||
ensure(missions);
|
||||
let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap();
|
||||
for mission in missions.members_mut() {
|
||||
if mission["master_mission_id"].as_i64() != Some(id) { continue; }
|
||||
if mission["status"].as_i64().unwrap_or(1) >= 2 { return false; }
|
||||
let progress = progress.max(mission["progress"].as_i64().unwrap_or(0)).min(target);
|
||||
mission["progress"] = progress.into();
|
||||
mission["status"] = if progress >= target { 2 } else { 1 }.into();
|
||||
return progress >= target;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn advance(condition: i64, value: Option<i64>, amount: i64, missions: &mut JsonValue) -> JsonValue {
|
||||
ensure(missions);
|
||||
let mut cleared = array![];
|
||||
for cell in CELLS.members() {
|
||||
let id = cell["masterMissionId"].as_i64().unwrap();
|
||||
let mst = &databases::MISSION_LIST[id.to_string()];
|
||||
if mst["conditionType"].as_i64() != Some(condition) { continue; }
|
||||
if let Some(value) = value {
|
||||
if mst["conditionValues"][0].as_i64() != Some(value) { continue; }
|
||||
}
|
||||
let old = items::get_mission_status(id, missions)["progress"].as_i64().unwrap_or(0);
|
||||
if set_progress(id, old + amount, missions) { cleared.push(id).unwrap(); }
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) -> JsonValue {
|
||||
ensure(missions);
|
||||
let mut cleared = array![];
|
||||
for cell in CELLS.members() {
|
||||
let id = cell["masterMissionId"].as_i64().unwrap();
|
||||
let mst = &databases::MISSION_LIST[id.to_string()];
|
||||
let value = mst["conditionValues"][0].as_i64().unwrap_or(0);
|
||||
let progress = match mst["conditionType"].as_i64().unwrap() {
|
||||
14 | 64 => {
|
||||
let skill = mst["conditionType"] == 64;
|
||||
user["card_list"].members().filter(|card| {
|
||||
let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0));
|
||||
let curve = if skill { databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"].as_i64() }
|
||||
else { master["masterCardLevelId"].as_i64() };
|
||||
let levels = if skill { &*SKILL_LEVELS } else { &*CARD_LEVELS };
|
||||
levels.members().any(|l| l["id"].as_i64() == curve && l["level"].as_i64() == Some(value)
|
||||
&& card[if skill { "skill_exp" } else { "exp" }].as_i64().unwrap_or(0) >= l["exp"].as_i64().unwrap())
|
||||
}).count() as i64
|
||||
}
|
||||
17 => user["card_list"].members().filter(|c| !c["evolve"].is_empty()).count() as i64,
|
||||
58 => user["character_list"].members().filter_map(|c| c["exp"].as_i64()).max().unwrap_or(0),
|
||||
30 | 60 => {
|
||||
let mut best = 0;
|
||||
for deck in user["deck_list"].members() {
|
||||
let mut types = [0i64; 3];
|
||||
let mut points = [0i64; 3];
|
||||
for slot in deck["main_card_ids"].members() {
|
||||
let Some(card) = user["card_list"].members().find(|c| c["id"] == *slot) else { continue; };
|
||||
let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0));
|
||||
if let Some(t) = master["type"].as_usize().filter(|t| (1..=3).contains(t)) { types[t-1] += 1; }
|
||||
let level = CARD_LEVELS.members().filter(|l| l["id"] == master["masterCardLevelId"]
|
||||
&& l["exp"].as_i64().unwrap() <= card["exp"].as_i64().unwrap_or(0))
|
||||
.max_by_key(|l| l["level"].as_i64());
|
||||
if let Some(l) = level {
|
||||
for (i, name) in ["smile", "pure", "cool"].iter().enumerate() {
|
||||
points[i] += master[*name].as_i64().unwrap_or(0) * l[format!("{}Ratio",name)].as_i64().unwrap_or(0) / 10000;
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = if mst["conditionType"] == 30 {
|
||||
let needed = mst["conditionValues"][1].as_i64().unwrap();
|
||||
(types.iter().any(|n| *n >= needed)) as i64
|
||||
} else { *points.iter().max().unwrap() };
|
||||
best = best.max(p);
|
||||
}
|
||||
best
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if set_progress(id, progress, missions) { cleared.push(id).unwrap(); }
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
pub fn refresh_account(user: &JsonValue, missions: &mut JsonValue) -> JsonValue {
|
||||
let mut cleared = refresh(user, missions);
|
||||
// Existing accounts may have prepared transfer credentials before missions
|
||||
// were enabled. Merely requesting a code leaves an empty password.
|
||||
if let Some(uid) = user["user"]["id"].as_i64() {
|
||||
if super::userdata::has_transfer_password(uid) {
|
||||
for id in advance(25, None, 1, missions).members() { cleared.push(id.clone()).unwrap(); }
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
fn sheet_cells(group: i64, level: i64) -> impl Iterator<Item = &'static JsonValue> {
|
||||
CELLS.members().filter(move |c| c["id"].as_i64() == Some(group) && c["level"].as_i64() == Some(level))
|
||||
}
|
||||
|
||||
fn claimed(group: i64, level: i64, missions: &JsonValue) -> Vec<i64> {
|
||||
sheet_cells(group, level).filter(|c| {
|
||||
items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3
|
||||
}).filter_map(|c| c["number"].as_i64()).collect()
|
||||
}
|
||||
|
||||
pub fn claimable(id: i64, missions: &JsonValue) -> bool {
|
||||
let Some(cell) = CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id)) else { return false; };
|
||||
CELLS.members().filter(|c| c["id"] == cell["id"] && c["level"].as_i64() < cell["level"].as_i64())
|
||||
.all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3)
|
||||
}
|
||||
|
||||
pub fn home_status(missions: &JsonValue) -> (usize, bool) {
|
||||
let all = CELLS.members().all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3);
|
||||
let count = CELLS.members().filter(|c| {
|
||||
let id = c["masterMissionId"].as_i64().unwrap();
|
||||
items::get_mission_status(id, missions)["status"] == 2 && claimable(id, missions)
|
||||
}).count();
|
||||
(count, all)
|
||||
}
|
||||
|
||||
pub fn new_rewards(before: &JsonValue, after: &JsonValue) -> Vec<JsonValue> {
|
||||
let mut settings: Vec<_> = SETTINGS.members().collect();
|
||||
// Captures return line milestones before the full-sheet reward.
|
||||
settings.sort_by_key(|s| (s["masterBeginnerMissionId"].as_i64(), s["level"].as_i64(),
|
||||
if s["number"] == 0 { 9 } else { s["number"].as_i64().unwrap() }));
|
||||
let mut result = Vec::new();
|
||||
for setting in settings {
|
||||
let group = setting["masterBeginnerMissionId"].as_i64().unwrap();
|
||||
let level = setting["level"].as_i64().unwrap();
|
||||
let number = setting["number"].as_i64().unwrap();
|
||||
// JP cross-sheet claims award bonuses only for sheets unlocked before
|
||||
// the request (recorded claims 8 and 11 in the replay fixture).
|
||||
let first = sheet_cells(group, level).next().unwrap()["masterMissionId"].as_i64().unwrap();
|
||||
if !claimable(first, before) { continue; }
|
||||
let achieved = |missions: &JsonValue| {
|
||||
let cells = claimed(group, level, missions);
|
||||
if number == 0 { cells.len() == 9 }
|
||||
else { LINES.iter().filter(|line| line.iter().all(|n| cells.contains(n))).count() >= number as usize }
|
||||
};
|
||||
if !achieved(before) && achieved(after) {
|
||||
result.extend(REWARDS.members().filter(|r| r["id"] == setting["masterBeginnerMissionRewardId"]).cloned());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn current_inventory_drives_training_bond_and_deck_goals() {
|
||||
let mut user = object! { card_list: [], character_list: [{exp: 30}], deck_list: [] };
|
||||
let master = super::super::custom_card::card_info(10010001);
|
||||
let exp = CARD_LEVELS.members().find(|l| l["id"] == master["masterCardLevelId"] && l["level"] == 30).unwrap()["exp"].clone();
|
||||
let curve = &databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"];
|
||||
let skill_exp = SKILL_LEVELS.members().find(|l| l["id"] == *curve && l["level"] == 2).unwrap()["exp"].clone();
|
||||
for id in 1..=5 {
|
||||
user["card_list"].push(object! {id: id, master_card_id: 10010001,
|
||||
exp: exp.clone(), skill_exp: skill_exp.clone(), evolve: [{type: 2, count: 1}]}).unwrap();
|
||||
}
|
||||
user["deck_list"].push(object! {main_card_ids: [1,2,3,4,5,0,0,0,0]}).unwrap();
|
||||
let mut missions = array![];
|
||||
let cleared = refresh(&user, &mut missions);
|
||||
for id in [1614001,1614002,1614003,1664001,1664002,1617001,1658001,1658002,1658003,1630001] {
|
||||
assert!(cleared.contains(id), "mission {}", id);
|
||||
}
|
||||
assert!(refresh(&user, &mut missions).is_empty());
|
||||
assert_eq!(items::get_mission_status(1605001, &missions)["progress"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counters_match_values_and_home_counts_only_unlocked_sheet() {
|
||||
let mut missions = array![];
|
||||
assert!(advance(63, Some(1110001), 20, &mut missions).is_empty());
|
||||
assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663001]);
|
||||
assert_eq!(items::get_mission_status(1663002, &missions)["progress"], 10);
|
||||
assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663002]);
|
||||
assert!(advance(6, Some(1), 1, &mut missions).is_empty());
|
||||
assert_eq!(advance(6, Some(2), 1, &mut missions), array![1606001]);
|
||||
advance(53, None, 3, &mut missions);
|
||||
assert_eq!(home_status(&missions), (2, false));
|
||||
for row in missions.members_mut() { row["status"] = 3.into(); }
|
||||
assert_eq!(home_status(&missions), (0, true));
|
||||
assert!(advance(53, None, 1, &mut missions).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_recorded_jp_beginner_claims_match() {
|
||||
let cases = jzon::parse(include_str!("beginner_mission_claims.json")).unwrap();
|
||||
assert_eq!(cases.len(), 15);
|
||||
for (i, case) in cases.members().enumerate() {
|
||||
let mut before = array![];
|
||||
ensure(&mut before);
|
||||
for row in before.members_mut() {
|
||||
if case["before"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); }
|
||||
}
|
||||
let mut after = before.clone();
|
||||
for row in after.members_mut() {
|
||||
if case["claim"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); }
|
||||
}
|
||||
let expected: Vec<_> = case["bonus"].members().map(|x| x.as_i64().unwrap()).collect();
|
||||
let actual: Vec<_> = new_rewards(&before, &after).iter().map(|x| x["amount"].as_i64().unwrap()).collect();
|
||||
assert_eq!(actual, expected, "JP claim {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_is_capped_and_claims_are_not_reset() {
|
||||
let mut m = array![];
|
||||
advance(5, None, 12, &mut m);
|
||||
assert_eq!(items::get_mission_status(1605001, &m)["progress"], 1);
|
||||
assert_eq!(items::get_mission_status(1605002, &m)["progress"], 10);
|
||||
assert_eq!(items::get_mission_status(1605003, &m)["progress"], 12);
|
||||
assert!(!claimable(1605002, &m));
|
||||
for row in m.members_mut() { if row["master_mission_id"] == 1605001 { row["status"] = 3.into(); } }
|
||||
advance(5, None, 1, &mut m);
|
||||
assert_eq!(items::get_mission_status(1605001, &m)["status"], 3);
|
||||
for row in m.members_mut() {
|
||||
if row["master_mission_id"] == 1653001 {
|
||||
row["status"] = 2.into();
|
||||
row["progress"] = 50.into();
|
||||
}
|
||||
}
|
||||
ensure(&mut m);
|
||||
assert_eq!(items::get_mission_status(1653001, &m)["progress"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_first_sheet_claim_awards_one_line() {
|
||||
let mut before = array![];
|
||||
ensure(&mut before);
|
||||
let mut after = before.clone();
|
||||
for row in after.members_mut() {
|
||||
if [1653001,1605001,1663001,1666001,1658001].contains(&row["master_mission_id"].as_i64().unwrap()) {
|
||||
row["status"] = 3.into();
|
||||
}
|
||||
}
|
||||
let r = new_rewards(&before, &after);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["amount"], 50);
|
||||
assert!(new_rewards(&after, &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_sheet_gives_eight_lines_and_sheet_once() {
|
||||
let mut before = array![];
|
||||
ensure(&mut before);
|
||||
let mut after = before.clone();
|
||||
for row in after.members_mut() {
|
||||
if sheet_cells(1,1).any(|c| c["masterMissionId"] == row["master_mission_id"]) { row["status"] = 3.into(); }
|
||||
}
|
||||
let r = new_rewards(&before, &after);
|
||||
assert_eq!(r.len(), 9);
|
||||
assert_eq!(r.iter().map(|r| r["amount"].as_i64().unwrap()).sum::<i64>(), 1000);
|
||||
assert!(claimable(1605002, &after));
|
||||
assert!(!home_status(&after).1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
[
|
||||
{
|
||||
"before": [],
|
||||
"claim": [
|
||||
1653001
|
||||
],
|
||||
"bonus": []
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1660001,
|
||||
1666001
|
||||
],
|
||||
"claim": [
|
||||
1605001,
|
||||
1663001,
|
||||
1614001,
|
||||
1658001,
|
||||
1661001
|
||||
],
|
||||
"bonus": [
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [],
|
||||
"claim": [
|
||||
1653001
|
||||
],
|
||||
"bonus": []
|
||||
},
|
||||
{
|
||||
"before": [],
|
||||
"claim": [
|
||||
1653001
|
||||
],
|
||||
"bonus": []
|
||||
},
|
||||
{
|
||||
"before": [],
|
||||
"claim": [
|
||||
1653001
|
||||
],
|
||||
"bonus": []
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001
|
||||
],
|
||||
"claim": [
|
||||
1605001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001
|
||||
],
|
||||
"bonus": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [],
|
||||
"claim": [
|
||||
1653001,
|
||||
1605001,
|
||||
1663001,
|
||||
1666001,
|
||||
1658001
|
||||
],
|
||||
"bonus": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1663001,
|
||||
1666001,
|
||||
1658001
|
||||
],
|
||||
"claim": [
|
||||
1614001
|
||||
],
|
||||
"bonus": []
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1663001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001
|
||||
],
|
||||
"claim": [
|
||||
1662001,
|
||||
1660001,
|
||||
1661001,
|
||||
1653002,
|
||||
1658002,
|
||||
1630001,
|
||||
1663002
|
||||
],
|
||||
"bonus": [
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
100,
|
||||
100,
|
||||
500
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1658002,
|
||||
1630001,
|
||||
1663002
|
||||
],
|
||||
"claim": [
|
||||
1665001
|
||||
],
|
||||
"bonus": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1665001,
|
||||
1658002,
|
||||
1630001,
|
||||
1663002
|
||||
],
|
||||
"claim": [
|
||||
1664001,
|
||||
1614002
|
||||
],
|
||||
"bonus": [
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1665001,
|
||||
1664001,
|
||||
1658002,
|
||||
1630001,
|
||||
1614002,
|
||||
1663002
|
||||
],
|
||||
"claim": [
|
||||
1605002,
|
||||
1606001,
|
||||
1653003,
|
||||
1660002,
|
||||
1617001,
|
||||
1658003,
|
||||
1625001
|
||||
],
|
||||
"bonus": [
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
100,
|
||||
100,
|
||||
500
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1605002,
|
||||
1665001,
|
||||
1664001,
|
||||
1658002,
|
||||
1630001,
|
||||
1606001,
|
||||
1614002,
|
||||
1663002,
|
||||
1653003,
|
||||
1660002,
|
||||
1617001,
|
||||
1658003,
|
||||
1625001
|
||||
],
|
||||
"claim": [
|
||||
1626001
|
||||
],
|
||||
"bonus": [
|
||||
50,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1605002,
|
||||
1665001,
|
||||
1664001,
|
||||
1658002,
|
||||
1630001,
|
||||
1606001,
|
||||
1614002,
|
||||
1663002,
|
||||
1653003,
|
||||
1660002,
|
||||
1617001,
|
||||
1658003,
|
||||
1625001,
|
||||
1626001
|
||||
],
|
||||
"claim": [
|
||||
1614003,
|
||||
1664002
|
||||
],
|
||||
"bonus": [
|
||||
50,
|
||||
50,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"before": [
|
||||
1653001,
|
||||
1605001,
|
||||
1662001,
|
||||
1663001,
|
||||
1660001,
|
||||
1614001,
|
||||
1666001,
|
||||
1658001,
|
||||
1661001,
|
||||
1653002,
|
||||
1605002,
|
||||
1665001,
|
||||
1664001,
|
||||
1658002,
|
||||
1630001,
|
||||
1606001,
|
||||
1614002,
|
||||
1663002,
|
||||
1653003,
|
||||
1660002,
|
||||
1617001,
|
||||
1658003,
|
||||
1625001,
|
||||
1614003,
|
||||
1664002,
|
||||
1626001
|
||||
],
|
||||
"claim": [
|
||||
1605003
|
||||
],
|
||||
"bonus": [
|
||||
100,
|
||||
100,
|
||||
500
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -134,6 +134,11 @@ async fn reinforce(Session { key, body }: Session) -> impl Responder {
|
||||
let mut clear_mission_ids = array![];
|
||||
|
||||
let card = do_reinforce(&mut user, &body, "exp", 1, false, &mut array![], &mut array![], &mut clear_mission_ids);
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
for id in super::beginner_mission::refresh(&user, &mut missions).members() {
|
||||
clear_mission_ids.push(id.clone()).unwrap();
|
||||
}
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
|
||||
@@ -150,6 +155,11 @@ async fn skill_reinforce(Session { key, body }: Session) -> impl Responder {
|
||||
let mut clear_mission_ids = array![];
|
||||
|
||||
let card = do_reinforce(&mut user, &body, "skill_exp", 10, false, &mut array![], &mut array![], &mut clear_mission_ids);
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
for id in super::beginner_mission::refresh(&user, &mut missions).members() {
|
||||
clear_mission_ids.push(id.clone()).unwrap();
|
||||
}
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
|
||||
@@ -168,6 +178,7 @@ async fn evolve(Session { key, body }: Session) -> impl Responder {
|
||||
let mut clear_mission_ids = array![];
|
||||
|
||||
let card = do_reinforce(&mut user, &body, "", 0, true, &mut missions, &mut chats, &mut clear_mission_ids);
|
||||
for id in super::beginner_mission::refresh(&user, &mut missions).members() { clear_mission_ids.push(id.clone()).unwrap(); }
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
userdata::save_acc_chats(&key, chats);
|
||||
|
||||
@@ -60,6 +60,7 @@ pub fn add_chat_from_chapter_id(chapter_id: i64, chats: &mut JsonValue) -> bool
|
||||
}
|
||||
|
||||
async fn home(Login(key): Login) -> impl Responder {
|
||||
refresh_birthday(&key, &userdata::get_acc(&key));
|
||||
let chats = userdata::get_acc_chats(&key);
|
||||
|
||||
let mut rooms = array![];
|
||||
@@ -75,6 +76,54 @@ async fn home(Login(key): Login) -> impl Responder {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn refresh_birthday(key: &str, user: &JsonValue) {
|
||||
if !super::user::valid_birthday(user["user"]["birthday"].as_str().unwrap_or("")) { return; }
|
||||
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
|
||||
if !is_birthday(user, now) { return; }
|
||||
let mut chats = userdata::get_acc_chats(key);
|
||||
let mut missions = userdata::get_acc_missions(key);
|
||||
if grant_birthday_chats(user, &mut chats, &mut missions, now) {
|
||||
userdata::save_acc_chats(key, chats);
|
||||
userdata::save_acc_missions(key, missions);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_birthday(user: &JsonValue, now: u64) -> bool {
|
||||
let date = global::format_datetime(now);
|
||||
let birthday = user["user"]["birthday"].as_str().unwrap_or("");
|
||||
birthday == format!("{}{}", &date[5..7], &date[8..10])
|
||||
}
|
||||
|
||||
fn grant_birthday_chats(user: &JsonValue, chats: &mut JsonValue, missions: &mut JsonValue, now: u64) -> bool {
|
||||
if !is_birthday(user, now) { return false; }
|
||||
let mut changed = false;
|
||||
for (_, master) in databases::MISSION_LIST.entries() {
|
||||
if master["conditionType"] != 75 { continue; }
|
||||
let character = &master["conditionValues"][0];
|
||||
let target = master["conditionNumber"].as_i64().unwrap();
|
||||
if !user["character_list"].members().any(|c|
|
||||
c["master_character_id"] == *character && c["exp"].as_i64().unwrap_or(0) >= target) { continue; }
|
||||
let id = master["id"].as_i64().unwrap();
|
||||
if items::get_mission_status(id, missions)["status"] == 3 { continue; }
|
||||
for reward in databases::MISSION_REWARDS[master["masterMissionRewardId"].to_string()].members().filter(|r| r["type"] == 16) {
|
||||
for (_, by_room) in databases::CHATS.entries() {
|
||||
for (_, room) in by_room.entries().filter(|(_, r)| r["id"] == reward["value"]) {
|
||||
add_chat(room["masterChatId"].as_i64().unwrap(), room["roomId"].as_i64().unwrap(), chats);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missions.members().any(|m| m["master_mission_id"] == id) {
|
||||
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
|
||||
}
|
||||
for mission in missions.members_mut().filter(|m| m["master_mission_id"] == id) {
|
||||
mission["status"] = 3.into();
|
||||
mission["progress"] = target.into();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
async fn start() -> impl Responder {
|
||||
Api(Some(object!{"select_talk_id_list":[],"get_item_list":[],"is_read":0}))
|
||||
}
|
||||
@@ -102,6 +151,25 @@ async fn end(Session { key, body }: Session) -> impl Responder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn birthday_chats_require_the_date_and_bond_and_are_not_duplicated() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let user = object! { user: {birthday: "0531"}, character_list: [
|
||||
{master_character_id: 1001, exp: 50}, {master_character_id: 1002, exp: 49}
|
||||
]};
|
||||
let mut chats = array![];
|
||||
let mut missions = array![];
|
||||
let day = global::parse_datetime("2024/05/31 12:00:00").unwrap();
|
||||
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day - 86400));
|
||||
assert!(grant_birthday_chats(&user, &mut chats, &mut missions, day));
|
||||
assert_eq!(chats.len(), 1);
|
||||
assert_eq!(chats[0]["chapter_id"], 100100301);
|
||||
assert_eq!(missions[0]["status"], 3);
|
||||
assert_eq!(missions[0]["progress"], 50);
|
||||
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day));
|
||||
assert_eq!(chats.len(), 1);
|
||||
}
|
||||
|
||||
// Verbatim from an official /api/chat/home capture (JP log, the 65-occurrence
|
||||
// baseline seen on accounts that had earned no extra stamps yet). Both the JP and EN
|
||||
// chat_stamp tables reproduce it exactly from _initialStamp, which is what lets
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read, Seek, Write};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use super::{blob_path, field_key};
|
||||
use crate::database::custom_3dmv as database;
|
||||
|
||||
// Export packages carry the stored blobs byte-for-byte (they ARE the original
|
||||
// uploads - nothing is transcoded) plus the upload metadata, so an MV can be
|
||||
// re-uploaded on any ew server. Layout of the zip:
|
||||
// manifest.json {format, name, name_en, music_id, member_count}
|
||||
// model_{slot} / motion_{slot} / facial_{slot} / camera / config / stage
|
||||
// published is a per-server setting and deliberately not part of the package.
|
||||
|
||||
pub fn build(mv_id: i64) -> Result<Vec<u8>, String> {
|
||||
let mv = database::get_mv(mv_id).ok_or(String::from("MV not found"))?;
|
||||
|
||||
let manifest = jzon::object!{
|
||||
"format": 1,
|
||||
"name": mv["name"].clone(),
|
||||
"name_en": mv["name_en"].clone(),
|
||||
"music_id": mv["music_id"].clone(),
|
||||
"member_count": mv["member_count"].clone()
|
||||
};
|
||||
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = SimpleFileOptions::default();
|
||||
let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> {
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
add("manifest.json", jzon::stringify(manifest).as_bytes())?;
|
||||
for file in mv["files"].members() {
|
||||
let Some(name) = field_key(file) else { continue; };
|
||||
let md5 = file["md5"].as_str().unwrap_or("");
|
||||
let bytes = fs::read(blob_path(md5)).map_err(|e| e.to_string())?;
|
||||
add(&name, &bytes)?;
|
||||
}
|
||||
|
||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||
}
|
||||
|
||||
// Reads one entry, capped. Deflate's ceiling is about 1032:1, so an uncapped
|
||||
// read_to_end here is a zip bomb: a one-megabyte entry inflates to a gigabyte and
|
||||
// grows the Vec until the allocator or the OOM killer stops it, and a package has
|
||||
// 39 addressable entries. Every entry is bounded by the upload form's own
|
||||
// per-file cap, and by what is left of the per-request budget across all entries.
|
||||
//
|
||||
// The central directory's declared size rejects the obvious case without
|
||||
// inflating anything; take(cap + 1) makes that declaration untrusted - a lying
|
||||
// header runs out of budget one byte past the cap and stops there.
|
||||
//
|
||||
// Ok(None) is "the package does not carry this entry", a normal outcome for every
|
||||
// optional role
|
||||
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str, remaining: &mut usize) -> Result<Option<Vec<u8>>, String> {
|
||||
let Ok(mut file) = archive.by_name(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cap = std::cmp::min(super::MAX_FILE_BYTES, *remaining);
|
||||
// Which limit the entry actually ran into, so the message names the right one
|
||||
let too_big = if cap >= super::MAX_FILE_BYTES {
|
||||
super::over_file_limit(name)
|
||||
} else {
|
||||
super::over_request_limit()
|
||||
};
|
||||
if file.size() > cap as u64 {
|
||||
return Err(too_big);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.by_ref().take(cap as u64 + 1).read_to_end(&mut bytes)
|
||||
.map_err(|_| format!("Package entry '{}' could not be read", name))?;
|
||||
if bytes.len() > cap {
|
||||
return Err(too_big);
|
||||
}
|
||||
*remaining -= bytes.len();
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
// Expands a package into the same field map the upload form produces. The
|
||||
// package's metadata wins over form fields - except music_id, which is a
|
||||
// server-local id: a form-supplied song wins, and the manifest's only fills
|
||||
// in when the form left it blank (same-server re-upload)
|
||||
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
|
||||
// The decompressed budget for the whole package, shared by every entry
|
||||
let mut remaining = super::MAX_REQUEST_BYTES;
|
||||
|
||||
let manifest = read_entry(&mut archive, "manifest.json", &mut remaining)?.ok_or(String::from("Package has no manifest.json"))?;
|
||||
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
|
||||
if manifest["format"].as_i64() != Some(1) {
|
||||
return Err(String::from("Unsupported package format"));
|
||||
}
|
||||
|
||||
for key in ["name", "name_en", "member_count"] {
|
||||
if !manifest[key].is_null() {
|
||||
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
||||
}
|
||||
}
|
||||
if !fields.get("music_id").is_some_and(|v| !v.is_empty()) && !manifest["music_id"].is_null() {
|
||||
fields.insert(String::from("music_id"), manifest["music_id"].to_string().into_bytes());
|
||||
}
|
||||
|
||||
let member_count = manifest["member_count"].as_i64().unwrap_or(0);
|
||||
for slot in 1..=member_count.clamp(0, super::MAX_MEMBER_COUNT) {
|
||||
for role in ["model", "motion", "facial"] {
|
||||
if let Some(bytes) = read_entry(&mut archive, &format!("{}_{}", role, slot), &mut remaining)? {
|
||||
fields.insert(format!("{}_{}", role, slot), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in ["camera", "config", "stage"] {
|
||||
if let Some(bytes) = read_entry(&mut archive, name, &mut remaining)? {
|
||||
fields.insert(String::from(name), bytes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Structural validation of a VMD (Vocaloid Motion Data) upload: the header
|
||||
// magic plus a full section walk with bounds checks, so a truncated or
|
||||
// corrupt file is rejected cheaply at upload time instead of crashing a
|
||||
// client mid-live. Nothing here decodes the animation - the stored bytes are
|
||||
// served verbatim and the client owns the semantics.
|
||||
//
|
||||
// Layout (little-endian): 30-byte magic "Vocaloid Motion Data 0002"
|
||||
// (NUL-padded), 20-byte model name, then up to 6 sections, each a uint32
|
||||
// count followed by fixed-size records - bone (111), morph (23), camera (61),
|
||||
// light (28), self-shadow (9) - and the property/IK section whose records are
|
||||
// variable-sized (9 bytes + 21 per IK entry). Camera-only and motion-only
|
||||
// files legitimately end early: EOF on a section boundary reads as count 0.
|
||||
|
||||
const MAGIC_V2: &[u8] = b"Vocaloid Motion Data 0002";
|
||||
const MAGIC_V1: &[u8] = b"Vocaloid Motion Data file";
|
||||
const HEADER_LEN: usize = 30 + 20;
|
||||
|
||||
// (record size, section name) for the fixed-size sections, in file order
|
||||
const FIXED_SECTIONS: &[(usize, &str)] = &[
|
||||
(111, "bone"),
|
||||
(23, "morph"),
|
||||
(61, "camera"),
|
||||
(28, "light"),
|
||||
(9, "self-shadow")
|
||||
];
|
||||
|
||||
fn read_count(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
let end = offset.checked_add(4)?;
|
||||
Some(u32::from_le_bytes(bytes.get(offset..end)?.try_into().unwrap()))
|
||||
}
|
||||
|
||||
pub fn validate(label: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if bytes.len() < HEADER_LEN {
|
||||
return Err(format!("'{}' is too short to be a VMD file", label));
|
||||
}
|
||||
if bytes.starts_with(MAGIC_V1) {
|
||||
return Err(format!("'{}' is a version 1 VMD (\"Vocaloid Motion Data file\") - re-save it as version 2 in MMD", label));
|
||||
}
|
||||
if !bytes.starts_with(MAGIC_V2) {
|
||||
return Err(format!("'{}' is not a VMD file (missing the \"Vocaloid Motion Data 0002\" header)", label));
|
||||
}
|
||||
|
||||
let mut offset = HEADER_LEN;
|
||||
for (record_size, section) in FIXED_SECTIONS {
|
||||
// EOF exactly on a section boundary: the remaining sections are absent
|
||||
if offset == bytes.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(count) = read_count(bytes, offset) else {
|
||||
return Err(format!("'{}' is truncated in the {} section header", label, section));
|
||||
};
|
||||
offset += 4;
|
||||
let section_len = (count as usize).checked_mul(*record_size)
|
||||
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
|
||||
.ok_or(format!("'{}' is truncated: the {} section claims {} records past the end of the file", label, section, count))?;
|
||||
offset += section_len;
|
||||
}
|
||||
|
||||
// Property/IK section: uint32 frame, byte visible, uint32 ikCount, then
|
||||
// ikCount x (20-byte bone name + 1-byte enabled)
|
||||
if offset == bytes.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(count) = read_count(bytes, offset) else {
|
||||
return Err(format!("'{}' is truncated in the property section header", label));
|
||||
};
|
||||
offset += 4;
|
||||
for _ in 0..count {
|
||||
let Some(ik_count) = read_count(bytes, offset + 5) else {
|
||||
return Err(format!("'{}' is truncated in the property section", label));
|
||||
};
|
||||
let record_len = (ik_count as usize).checked_mul(21)
|
||||
.and_then(|len| len.checked_add(9))
|
||||
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
|
||||
.ok_or(format!("'{}' is truncated in the property section", label))?;
|
||||
offset += record_len;
|
||||
}
|
||||
// Trailing bytes after the last section are tolerated, like MMD does
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structure_walk_accepts_real_shapes_and_rejects_corruption() {
|
||||
let vmd = crate::router::custom_3dmv::tests::test_vmd(1);
|
||||
assert!(validate("motion_1", &vmd).is_ok());
|
||||
|
||||
// Camera-only file: sections end after camera
|
||||
let cam = crate::router::custom_3dmv::tests::test_camera_vmd(2);
|
||||
assert!(validate("camera", &cam).is_ok());
|
||||
|
||||
// A bare header with every section absent is structurally fine
|
||||
let mut bare = Vec::new();
|
||||
bare.extend(MAGIC_V2);
|
||||
bare.resize(HEADER_LEN, 0);
|
||||
assert!(validate("motion_1", &bare).is_ok());
|
||||
|
||||
// Truncations and lies
|
||||
assert!(validate("motion_1", &vmd[..vmd.len() - 1]).unwrap_err().contains("truncated"));
|
||||
assert!(validate("motion_1", &vmd[..HEADER_LEN + 2]).unwrap_err().contains("truncated"));
|
||||
let mut liar = vmd.clone();
|
||||
liar[HEADER_LEN] = 200; // bone count far past EOF
|
||||
assert!(validate("motion_1", &liar).unwrap_err().contains("claims 200 records"));
|
||||
// A count that would overflow the length math
|
||||
let mut overflow = bare.clone();
|
||||
overflow.extend(u32::MAX.to_le_bytes());
|
||||
assert!(validate("motion_1", &overflow).unwrap_err().contains("truncated"));
|
||||
|
||||
// Wrong or old magic
|
||||
assert!(validate("motion_1", b"garbage").unwrap_err().contains("too short"));
|
||||
let mut wrong = vmd.clone();
|
||||
wrong[0] = b'X';
|
||||
assert!(validate("motion_1", &wrong).unwrap_err().contains("not a VMD"));
|
||||
let mut v1 = vmd.clone();
|
||||
v1[..MAGIC_V1.len()].copy_from_slice(MAGIC_V1);
|
||||
assert!(validate("motion_1", &v1).unwrap_err().contains("version 1"));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
|
||||
use crate::router::databases::csv::{table, Region};
|
||||
use crate::router::custom_song::audio;
|
||||
use crate::database::custom_card as database;
|
||||
use crate::database::custom_group;
|
||||
use crate::database::permissions;
|
||||
use crate::runtime::get_data_path;
|
||||
use crate::lock_onto_mutex;
|
||||
@@ -52,6 +53,13 @@ pub const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_REQUEST_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const MAX_CARDS_PER_USER: i64 = 500;
|
||||
|
||||
// Per-account storage quota over the stored art and voiceline bytes, shared by
|
||||
// cards and characters (they share one storage root). One card derives 14 PNGs up
|
||||
// to 2048x1260, so the 500-card count limit alone allowed several gigabytes per
|
||||
// account with no byte bound at all; 2GiB is around a hundred full cards, well
|
||||
// past any real uploader, and matches the custom-song quota
|
||||
pub const MAX_BYTES_PER_USER: i64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
// Columns the uploader never supplies. master_release_label_id must be 1: a
|
||||
// closed label filters the card out of the member-list filters and drops its
|
||||
// evolve conditions
|
||||
@@ -127,6 +135,24 @@ const CHARACTER_ART: &[ArtKind] = &[
|
||||
ArtKind { kind: "character", width: 600, height: 920 }
|
||||
];
|
||||
|
||||
// One uploadable voiceline moment. `name` is the wire kind: it is the infix of
|
||||
// the multipart field names below, the "kind" of every stored/served line, and
|
||||
// what the client maps to a SYSTEM_VOICE_TYPE (and, for the day_* moments, to
|
||||
// the date_condition id the game's resolver compares). `category` only groups
|
||||
// the upload form.
|
||||
//
|
||||
// The home-screen moments (category "home") are exactly the SYSTEM_VOICE_TYPEs
|
||||
// HomeScene.CreateVoiceData asks the resolver for; everything else is a moment
|
||||
// that already shipped. Kind names are the snake_case of the game's own
|
||||
// SYSTEM_VOICE_TYPE members (day_* follow the game's own SYS_VOICE_DAY_MMDD cue
|
||||
// naming), so the mapping stays readable on both sides.
|
||||
pub struct VoiceKind {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
pub label_en: &'static str,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
// Optional voicelines per character. Multipart fields per line:
|
||||
// voice_{kind}_{index} audio file (any format symphonia reads;
|
||||
// transcoded to ogg-vorbis, stored
|
||||
@@ -137,9 +163,71 @@ const CHARACTER_ART: &[ArtKind] = &[
|
||||
// Absent slots keep their stored line, captions without a file update the
|
||||
// stored line's captions, and surviving lines are renumbered contiguously
|
||||
// per kind (1..n) after every edit
|
||||
const VOICE_KINDS: &[&str] = &[
|
||||
"live_start", "live_success", "live_failed", "result_bond",
|
||||
"skill_smile", "skill_pure", "skill_cool"
|
||||
pub const VOICE_KINDS: &[VoiceKind] = &[
|
||||
// --- moments outside the home screen (unchanged wire names) ---
|
||||
VoiceKind { name: "live_start", label: "ライブ開始(リーダー)", label_en: "Live start (leader, before a live)", category: "live" },
|
||||
VoiceKind { name: "live_success", label: "ライブクリア", label_en: "Live cleared", category: "live" },
|
||||
VoiceKind { name: "live_failed", label: "ライブ失敗", label_en: "Live failed", category: "live" },
|
||||
VoiceKind { name: "result_bond", label: "リザルト・絆獲得", label_en: "Bond gained (live result)", category: "result" },
|
||||
VoiceKind { name: "skill_smile", label: "スキル発動(スマイル)", label_en: "Skill activation - Smile cards", category: "skill" },
|
||||
VoiceKind { name: "skill_pure", label: "スキル発動(ピュア)", label_en: "Skill activation - Pure cards", category: "skill" },
|
||||
VoiceKind { name: "skill_cool", label: "スキル発動(クール)", label_en: "Skill activation - Cool cards", category: "skill" },
|
||||
|
||||
// --- home screen: always in the pool ---
|
||||
VoiceKind { name: "random", label: "ランダム", label_en: "Random line (always in the pool)", category: "home" },
|
||||
VoiceKind { name: "random_evolution", label: "ランダム(覚醒後)", label_en: "Random line, awakened card only", category: "home" },
|
||||
VoiceKind { name: "random_unevolution_smile", label: "ランダム(未覚醒・スマイル)", label_en: "Random line, un-awakened Smile card", category: "home" },
|
||||
VoiceKind { name: "random_unevolution_pure", label: "ランダム(未覚醒・ピュア)", label_en: "Random line, un-awakened Pure card", category: "home" },
|
||||
VoiceKind { name: "random_unevolution_cool", label: "ランダム(未覚醒・クール)", label_en: "Random line, un-awakened Cool card", category: "home" },
|
||||
VoiceKind { name: "random_evolution_smile", label: "ランダム(覚醒後・スマイル)", label_en: "Random line, awakened Smile card", category: "home" },
|
||||
VoiceKind { name: "random_evolution_pure", label: "ランダム(覚醒後・ピュア)", label_en: "Random line, awakened Pure card", category: "home" },
|
||||
VoiceKind { name: "random_evolution_cool", label: "ランダム(覚醒後・クール)", label_en: "Random line, awakened Cool card", category: "home" },
|
||||
VoiceKind { name: "touch", label: "タップ", label_en: "Tapped (the touch-to-play-voice button)", category: "home" },
|
||||
|
||||
// --- home screen: time of day (the game's own hour bands) ---
|
||||
VoiceKind { name: "time", label: "時間帯(共通)", label_en: "Any time of day", category: "home" },
|
||||
VoiceKind { name: "time_firsthalf", label: "時間帯(5時〜17時)", label_en: "First half of the day (05-17)", category: "home" },
|
||||
VoiceKind { name: "time_latterhalf", label: "時間帯(17時〜5時)", label_en: "Latter half of the day (17-05)", category: "home" },
|
||||
VoiceKind { name: "time_morning", label: "時間帯(朝・5時〜11時)", label_en: "Morning (05-11)", category: "home" },
|
||||
VoiceKind { name: "time_noon", label: "時間帯(昼・11時〜17時)", label_en: "Daytime (11-17)", category: "home" },
|
||||
VoiceKind { name: "time_evening", label: "時間帯(夕方・17時〜23時)", label_en: "Evening (17-23)", category: "home" },
|
||||
VoiceKind { name: "time_night", label: "時間帯(夜・23時〜5時)", label_en: "Night (23-05)", category: "home" },
|
||||
|
||||
// --- home screen: season (the game's own month bands) ---
|
||||
VoiceKind { name: "season_spring", label: "季節(春・3〜5月)", label_en: "Spring (March-May)", category: "home" },
|
||||
VoiceKind { name: "season_summer", label: "季節(夏・6〜8月)", label_en: "Summer (June-August)", category: "home" },
|
||||
VoiceKind { name: "season_autumn", label: "季節(秋・9〜11月)", label_en: "Autumn (September-November)", category: "home" },
|
||||
VoiceKind { name: "season_winter", label: "季節(冬・12〜2月)", label_en: "Winter (December-February)", category: "home" },
|
||||
|
||||
// --- home screen: calendar days. On a day that matches, the home screen
|
||||
// asks for NOTHING ELSE (a matching day line pre-empts the whole pool
|
||||
// unless the player taps), so these are the "special occasion" lines
|
||||
VoiceKind { name: "day_0101", label: "記念日(1月1日・お正月)", label_en: "January 1 (New Year's Day)", category: "home" },
|
||||
VoiceKind { name: "day_0203", label: "記念日(2月3日・節分)", label_en: "February 3 (Setsubun)", category: "home" },
|
||||
VoiceKind { name: "day_0214", label: "記念日(2月14日・バレンタイン)", label_en: "February 14 (Valentine's Day)", category: "home" },
|
||||
VoiceKind { name: "day_0303", label: "記念日(3月3日・ひな祭り)", label_en: "March 3 (Hinamatsuri)", category: "home" },
|
||||
VoiceKind { name: "day_0314", label: "記念日(3月14日・ホワイトデー)", label_en: "March 14 (White Day)", category: "home" },
|
||||
VoiceKind { name: "day_0505", label: "記念日(5月5日・こどもの日)", label_en: "May 5 (Children's Day)", category: "home" },
|
||||
VoiceKind { name: "day_0707", label: "記念日(7月7日・七夕)", label_en: "July 7 (Tanabata)", category: "home" },
|
||||
VoiceKind { name: "day_0717", label: "記念日(7月第3月曜・海の日)", label_en: "Marine Day (third Monday of July)", category: "home" },
|
||||
VoiceKind { name: "day_1015", label: "記念日(10月15日)", label_en: "October 15", category: "home" },
|
||||
VoiceKind { name: "day_1031", label: "記念日(10月31日・ハロウィン)", label_en: "October 31 (Halloween)", category: "home" },
|
||||
VoiceKind { name: "day_1225", label: "記念日(12月25日・クリスマス)", label_en: "December 25 (Christmas)", category: "home" },
|
||||
VoiceKind { name: "day_1231", label: "記念日(12月31日・大晦日)", label_en: "December 31 (New Year's Eve)", category: "home" },
|
||||
|
||||
// --- home screen: lines keyed to what the account has waiting ---
|
||||
VoiceKind { name: "advice_story", label: "未読ストーリーあり(おすすめ)", label_en: "Unread story waiting (advice line)", category: "home" },
|
||||
VoiceKind { name: "trigger_story", label: "未読ストーリーあり(反応)", label_en: "Unread story waiting (trigger line)", category: "home" },
|
||||
VoiceKind { name: "advice_mission", label: "デイリーミッション報酬未受取", label_en: "Unclaimed daily mission reward", category: "home" },
|
||||
VoiceKind { name: "advice_live", label: "LPが足りている(ライブ)", label_en: "Enough LP to play a live", category: "home" },
|
||||
VoiceKind { name: "advice_lp", label: "LP半分以下(回復のすすめ)", label_en: "LP below half, recovery affordable", category: "home" },
|
||||
VoiceKind { name: "trigger_shop", label: "LP半分以下(ショップ)", label_en: "LP below half, recovery affordable (shop line)", category: "home" },
|
||||
VoiceKind { name: "advice_lesson", label: "レッスン(未カンスト)", label_en: "A unit is not fully levelled", category: "home" },
|
||||
VoiceKind { name: "advice_present", label: "プレゼント未受取", label_en: "Unclaimed present waiting", category: "home" },
|
||||
VoiceKind { name: "advice_gacha", label: "無料スカウト可能", label_en: "The daily free scouting is available", category: "home" },
|
||||
VoiceKind { name: "advice_infomation", label: "新着お知らせあり", label_en: "A new announcement is up", category: "home" },
|
||||
VoiceKind { name: "advice_event", label: "イベント開催中", label_en: "An event is running", category: "home" },
|
||||
VoiceKind { name: "trigger_friend", label: "イベント中・フレンド", label_en: "An event is running and a friend is active", category: "home" }
|
||||
];
|
||||
const MAX_VOICE_VARIANTS: usize = 9;
|
||||
const MAX_VOICE_BYTES: usize = 4 * 1024 * 1024;
|
||||
@@ -299,6 +387,79 @@ lazy_static! {
|
||||
}
|
||||
rv
|
||||
};
|
||||
|
||||
// NERF_CUSTOM_CARDS: per-rarity (hp, smile, cool, pure) ceilings set ~200
|
||||
// below the median official card of that rarity (official medians: R ~2160,
|
||||
// SR ~4010, UR ~4366 -> caps 2000 / 3800 / 4200), so a nerfed custom card
|
||||
// stays under a typical official one. Skill magnitudes nerf to 4/5 of the
|
||||
// official ranges
|
||||
static ref NERF_STAT_CAPS: HashMap<i64, (i64, i64, i64, i64)> = {
|
||||
let mut rv: HashMap<i64, (i64, i64, i64, i64)> = HashMap::new();
|
||||
for (&rarity, (hp, _, _, _)) in &*STAT_CAPS {
|
||||
let cap = match rarity {
|
||||
1 => 2000,
|
||||
2 => 3800,
|
||||
_ => 4200,
|
||||
};
|
||||
rv.insert(rarity, (*hp, cap, cap, cap));
|
||||
}
|
||||
rv
|
||||
};
|
||||
}
|
||||
|
||||
// The nerf band. Read from the runtime flag rather than get_args(): the
|
||||
// host-config path (set_nerf_custom_cards, mirrored by overlay_args) is the
|
||||
// one the lib mode and the tests reach, and get_args() chokes on the test
|
||||
// harness's own CLI arguments (the same reason set_time is avoided in tests)
|
||||
pub fn nerf_active() -> bool {
|
||||
crate::runtime::get_nerf_custom_cards()
|
||||
}
|
||||
|
||||
// Skill magnitudes nerf to 4/5 of the official range
|
||||
fn nerf_scale(value: i64) -> i64 {
|
||||
if value > 0 { (value * 4 / 5).max(1) } else { value * 4 / 5 }
|
||||
}
|
||||
|
||||
// A stored skill array element above the cap moves down to the cap; below it
|
||||
// is untouched
|
||||
fn nerf_clamp_element(value: &mut JsonValue, cap: i64) {
|
||||
if cap > 0 && value.as_i64().unwrap_or(0) > cap {
|
||||
*value = cap.into();
|
||||
}
|
||||
}
|
||||
|
||||
// NERF_CUSTOM_CARDS clamps a card's stored stats and skill magnitudes to the
|
||||
// nerf band WITHOUT touching the db row - the db keeps the owner's values,
|
||||
// every response carries the clamped ones. No-op below the caps, so an
|
||||
// ordinary card passes through unchanged
|
||||
fn nerf_clamp_card(card: &mut JsonValue) {
|
||||
let rarity = card["rarity"].as_i64().unwrap_or(0);
|
||||
if let Some((hp, smile, cool, pure)) = NERF_STAT_CAPS.get(&rarity) {
|
||||
for (key, cap) in [("hp", *hp), ("smile", *smile), ("cool", *cool), ("pure", *pure)] {
|
||||
nerf_clamp_element(&mut card[key], cap);
|
||||
}
|
||||
}
|
||||
if card["skill"].is_null() {
|
||||
return;
|
||||
}
|
||||
let effect_type = card["skill"]["effect_type"].as_i64().unwrap_or(0);
|
||||
let trigger = card["skill"]["trigger"].as_i64().unwrap_or(0);
|
||||
let (_, value_max, _) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(0, 0, "official"));
|
||||
let (_, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(0, 0));
|
||||
let ((_, prob_max), (_, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
for (key, cap) in [
|
||||
("trigger_value", nerf_scale(trigger_max)),
|
||||
("probability", nerf_scale(prob_max)),
|
||||
("effective_milli_secs", nerf_scale(ms_max)),
|
||||
("effective_values", nerf_scale(value_max))
|
||||
] {
|
||||
if !card["skill"][key].is_array() {
|
||||
continue;
|
||||
}
|
||||
for value in card["skill"][key].members_mut() {
|
||||
nerf_clamp_element(value, cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game endpoints (/api scope, standard envelope)
|
||||
@@ -438,10 +599,15 @@ pub fn strip_unsupported(user: &mut JsonValue) {
|
||||
// The concrete upload bounds, served to the webui so the form can enforce
|
||||
// them client-side (sliders/radios) and reject out-of-range values instantly
|
||||
pub fn upload_limits() -> JsonValue {
|
||||
let nerfed = nerf_active();
|
||||
let mut stat_caps = object!{};
|
||||
let mut skill_levels = object!{};
|
||||
for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX {
|
||||
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
|
||||
let caps = if nerfed {
|
||||
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
|
||||
} else {
|
||||
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
|
||||
};
|
||||
stat_caps[rarity.to_string()] = object!{
|
||||
"hp": caps.0,
|
||||
"smile": caps.1,
|
||||
@@ -466,11 +632,12 @@ pub fn upload_limits() -> JsonValue {
|
||||
}
|
||||
// The skill magnitude envelopes, so the form's number inputs carry real
|
||||
// min/max and reject out-of-range (and e-notation) before submitting
|
||||
let scale = |v: i64| if nerfed { nerf_scale(v) } else { v };
|
||||
let mut effect_value_ranges = object!{};
|
||||
for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() {
|
||||
effect_value_ranges[effect.to_string()] = object!{
|
||||
"min": *min,
|
||||
"max": *max,
|
||||
"max": scale(*max),
|
||||
"source": *source
|
||||
};
|
||||
}
|
||||
@@ -478,10 +645,23 @@ pub fn upload_limits() -> JsonValue {
|
||||
for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() {
|
||||
trigger_value_ranges[trigger.to_string()] = object!{
|
||||
"min": *min,
|
||||
"max": *max
|
||||
"max": scale(*max)
|
||||
};
|
||||
}
|
||||
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
let prob_max = scale(prob_max);
|
||||
let ms_max = scale(ms_max);
|
||||
// The voiceline moments, so the form renders exactly the kinds this build
|
||||
// accepts instead of carrying its own copy of the list
|
||||
let mut voice_kinds = array![];
|
||||
for kind in VOICE_KINDS {
|
||||
voice_kinds.push(object!{
|
||||
"name": kind.name,
|
||||
"label": kind.label,
|
||||
"label_en": kind.label_en,
|
||||
"category": kind.category
|
||||
}).unwrap();
|
||||
}
|
||||
object!{
|
||||
"stat_caps": stat_caps,
|
||||
"skill_levels": skill_levels,
|
||||
@@ -498,7 +678,15 @@ pub fn upload_limits() -> JsonValue {
|
||||
"probability": { "min": prob_min, "max": prob_max },
|
||||
"milli_secs": { "min": ms_min, "max": ms_max }
|
||||
},
|
||||
"min_source_dim": art::MIN_SOURCE_DIM
|
||||
"min_source_dim": art::MIN_SOURCE_DIM,
|
||||
"max_file_bytes": MAX_FILE_BYTES,
|
||||
"max_request_bytes": MAX_REQUEST_BYTES,
|
||||
"max_cards_per_user": MAX_CARDS_PER_USER,
|
||||
"max_bytes_per_user": MAX_BYTES_PER_USER,
|
||||
"voice_kinds": voice_kinds,
|
||||
"max_voice_variants": MAX_VOICE_VARIANTS,
|
||||
"max_voice_bytes": MAX_VOICE_BYTES,
|
||||
"max_voice_seconds": MAX_VOICE_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,17 +701,30 @@ pub fn owned_runtime_ids(user: &JsonValue) -> Vec<i64> {
|
||||
// The catalog is filtered per requesting user: everyone gets the published
|
||||
// cards, the owner additionally gets their drafts, and a game account that
|
||||
// already owns a card keeps resolving it even if it was since unpublished
|
||||
async fn list(Login(key): Login) -> impl Responder {
|
||||
async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||
if disabled() {
|
||||
// As if the endpoint doesn't exist - the client treats this as feature-off
|
||||
return Api(None);
|
||||
}
|
||||
let user = userdata::get_acc(&key);
|
||||
let uid = user["user"]["id"].as_i64().unwrap();
|
||||
let cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
|
||||
let characters = database::get_characters_for_cards(uid, &cards);
|
||||
let mut cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
|
||||
if nerf_active() {
|
||||
for card in cards.members_mut() {
|
||||
nerf_clamp_card(card);
|
||||
}
|
||||
}
|
||||
let mut characters = database::get_characters_for_cards(uid, &cards);
|
||||
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
|
||||
for character in characters.members_mut() {
|
||||
if character["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
|
||||
character["master_group_id"] = CHARACTER_GROUP_ID.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Api(Some(object!{
|
||||
"revision": database::get_revision(),
|
||||
"groups": custom_group::list(),
|
||||
"characters": characters,
|
||||
"cards": cards
|
||||
}))
|
||||
@@ -559,6 +760,17 @@ async fn data(req: HttpRequest) -> HttpResponse {
|
||||
return HttpResponse::NotFound().finish();
|
||||
};
|
||||
match fs::read(asset_path(&relative)) {
|
||||
// Art lives at FIXED per-card filenames ({kind}_{variant}.png) that an
|
||||
// in-place edit overwrites, so between the file write and the catalog
|
||||
// update the index still points an old md5 at bytes that are no longer its
|
||||
// own. The client caches whatever it downloads under the md5 it asked for
|
||||
// and never re-checks, so serving those bytes would poison its cache
|
||||
// permanently. The index is a hint; these bytes are the answer only if
|
||||
// they hash to the request. A mismatch is the same 404 a stale md5 already
|
||||
// gets, and the client re-downloads under the md5 the catalog now carries
|
||||
Ok(body) if !hash.eq_ignore_ascii_case(&format!("{:x}", md5::compute(&body))) => {
|
||||
HttpResponse::NotFound().finish()
|
||||
},
|
||||
Ok(body) => {
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::png())
|
||||
@@ -840,11 +1052,29 @@ fn write_art(dir: &str, pending: &[PendingArt]) -> Result<(), String> {
|
||||
fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue, Vec<(String, Vec<u8>)>), String> {
|
||||
let mut rv = array![];
|
||||
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// An unknown moment is a typo, and silently ignoring it (what happens to
|
||||
// every other unrecognised multipart field) would show the uploader a clip
|
||||
// that uploaded fine and a line that never appeared. An out-of-range INDEX
|
||||
// stays ignored - that is a full slot list, not a misspelling
|
||||
for key in fields.keys() {
|
||||
let Some(rest) = key.strip_prefix("voice_") else { continue; };
|
||||
let rest = ["_text_en", "_text", "_delete"].iter()
|
||||
.find_map(|suffix| rest.strip_suffix(suffix))
|
||||
.unwrap_or(rest);
|
||||
let named_moment = match rest.rsplit_once('_') {
|
||||
Some((kind, index)) => index.parse::<usize>().is_ok() && VOICE_KINDS.iter().any(|k| k.name == kind),
|
||||
None => false
|
||||
};
|
||||
if !named_moment {
|
||||
return Err(format!("'{}' is not a voiceline field", key));
|
||||
}
|
||||
}
|
||||
for kind in VOICE_KINDS {
|
||||
let kind = kind.name;
|
||||
let mut lines: Vec<JsonValue> = Vec::new();
|
||||
for index in 1..=MAX_VOICE_VARIANTS {
|
||||
let base = format!("voice_{}_{}", kind, index);
|
||||
let stored_line = stored_voice.members().find(|line| line["kind"] == *kind && line["index"] == index);
|
||||
let stored_line = stored_voice.members().find(|line| line["kind"] == kind && line["index"] == index);
|
||||
if field_flag(fields, &format!("{}_delete", base)) {
|
||||
if file_of(fields, &base).is_some() {
|
||||
return Err(format!("'{}': cannot both replace and delete the same line", base));
|
||||
@@ -869,7 +1099,7 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue
|
||||
}
|
||||
let clip = audio::process_one_shot(bytes, MAX_VOICE_SECONDS).map_err(|e| format!("'{}': {}", base, e))?;
|
||||
lines.push(object!{
|
||||
"kind": *kind,
|
||||
"kind": kind,
|
||||
"index": 0,
|
||||
"md5": clip.md5.clone(),
|
||||
"size": clip.bytes.len(),
|
||||
@@ -879,7 +1109,7 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue
|
||||
files.push((clip.md5, clip.bytes));
|
||||
} else if let Some(stored_line) = stored_line {
|
||||
lines.push(object!{
|
||||
"kind": *kind,
|
||||
"kind": kind,
|
||||
"index": 0,
|
||||
"md5": stored_line["md5"].clone(),
|
||||
"size": stored_line["size"].clone(),
|
||||
@@ -917,9 +1147,12 @@ fn write_voice(master_character_id: i64, files: &[(String, Vec<u8>)]) -> Result<
|
||||
// (their old catalog md5 404s, exactly like replaced art)
|
||||
fn gc_voice(master_character_id: i64, old_voice: &JsonValue, new_voice: &JsonValue) {
|
||||
for old in old_voice.members() {
|
||||
let md5 = old["md5"].to_string();
|
||||
if !md5.is_empty() && !new_voice.members().any(|line| line["md5"] == old["md5"]) {
|
||||
let _ = fs::remove_file(voice_path(master_character_id, &md5));
|
||||
// JsonValue::to_string renders Null as the literal "null", which an
|
||||
// is_empty() guard happily passes; only exactly 32 hex characters is a
|
||||
// hash (the same test custom_3dmv's blob GC uses)
|
||||
let Some(md5) = old["md5"].as_str().filter(|md5| md5.len() == 32 && md5.chars().all(|c| c.is_ascii_hexdigit())) else { continue; };
|
||||
if !new_voice.members().any(|line| line["md5"] == old["md5"]) {
|
||||
let _ = fs::remove_file(voice_path(master_character_id, md5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -983,13 +1216,21 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
|
||||
|
||||
// Note the official HP scale before assuming a bug report: hp is a tiny
|
||||
// per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4)
|
||||
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
|
||||
// NERF_CUSTOM_CARDS drops the ceiling from the official rarity max to the
|
||||
// nerf band (~200 below the official median) for this upload
|
||||
let caps = if nerf_active() {
|
||||
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
|
||||
} else {
|
||||
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
|
||||
};
|
||||
let nerfed = nerf_active();
|
||||
let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)];
|
||||
let mut values = Vec::new();
|
||||
for (key, cap) in stats {
|
||||
let value = number_of(fields, key, stored, key);
|
||||
if !(1..=cap).contains(&value) {
|
||||
return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name));
|
||||
let range = if nerfed { "nerfed" } else { "official" };
|
||||
return Err(format!("{} must be between 1 and {} for a {} card (the {} {} range)", key, cap, rarity_name, range, rarity_name));
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
@@ -1039,17 +1280,22 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
|
||||
// effective_milli_secs may be 1 long). MAGNITUDES are clamped to the
|
||||
// ranges the shipped skill rows actually use - a 40-second buff and a
|
||||
// 1e8 score-up both made it through before these bounds existed
|
||||
// NERF_CUSTOM_CARDS drops the magnitude ceilings to 4/5 of the official
|
||||
// range; the floor stays the official one so a nerfed card can never be
|
||||
// weaker than the weakest official row of that effect
|
||||
let nerfed = nerf_active();
|
||||
let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1));
|
||||
let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official"));
|
||||
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
||||
let range_word = if nerfed { "nerfed" } else { "official" };
|
||||
let bounds = [
|
||||
("skill_trigger_value", "trigger_value", trigger_min, trigger_max,
|
||||
format!("for trigger {} (the official range)", trigger)),
|
||||
("skill_probability", "probability", prob_min, prob_max,
|
||||
format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)),
|
||||
("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max,
|
||||
String::from("milliseconds (the official range)")),
|
||||
("skill_effective_values", "effective_values", value_min, value_max,
|
||||
("skill_trigger_value", "trigger_value", trigger_min, if nerfed { nerf_scale(trigger_max) } else { trigger_max },
|
||||
format!("for trigger {} (the {} range)", trigger, range_word)),
|
||||
("skill_probability", "probability", prob_min, if nerfed { nerf_scale(prob_max) } else { prob_max },
|
||||
format!("({}%-{}%, the {} range)", prob_min / 1000, (if nerfed { nerf_scale(prob_max) } else { prob_max }) / 1000, range_word)),
|
||||
("skill_effective_milli_secs", "effective_milli_secs", ms_min, if nerfed { nerf_scale(ms_max) } else { ms_max },
|
||||
format!("milliseconds (the {} range)", range_word)),
|
||||
("skill_effective_values", "effective_values", value_min, if nerfed { nerf_scale(value_max) } else { value_max },
|
||||
format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source))
|
||||
];
|
||||
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
|
||||
@@ -1115,6 +1361,13 @@ fn valid_color(color: &str) -> bool {
|
||||
// Every column the uploader never supplies is forced to the value all 172
|
||||
// imported characters carry. Numbers, not enum names
|
||||
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
||||
let group_text = text_of(fields, "character_master_group_id", stored, "master_group_id");
|
||||
let group_id = if group_text.is_empty() { CHARACTER_GROUP_ID } else {
|
||||
group_text.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?
|
||||
};
|
||||
if group_id != CHARACTER_GROUP_ID && !custom_group::exists(group_id) {
|
||||
return Err(String::from("Custom group does not exist"));
|
||||
}
|
||||
for (key, label) in [
|
||||
("character_name", "Character name"),
|
||||
("character_name_en", "Character English name"),
|
||||
@@ -1161,7 +1414,7 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue
|
||||
"category": CHARACTER_CATEGORY_OTHER,
|
||||
"school_grade": CHARACTER_SCHOOL_GRADE,
|
||||
"chara_category": CHARACTER_CHARA_CATEGORY,
|
||||
"master_group_id": CHARACTER_GROUP_ID,
|
||||
"master_group_id": group_id,
|
||||
"sprite_name": "",
|
||||
"display_order": master_character_id,
|
||||
"height": text("height"),
|
||||
@@ -1215,15 +1468,31 @@ pub fn create_card(uid: i64, fields: &Fields) -> Result<i64, String> {
|
||||
|
||||
let mut card = build_card(master_card_id, master_character_id, fields, &object!{})?;
|
||||
card["art"] = merge_art(&array![], &card_art);
|
||||
check_quota(uid, database::card_bytes(&card), 0, 0)?;
|
||||
|
||||
write_art(&card_dir(master_card_id), &card_art)?;
|
||||
database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable);
|
||||
database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable)
|
||||
.map_err(|e| format!("Could not store the card: {}", e))?;
|
||||
database::bump_revision();
|
||||
drop(lock);
|
||||
|
||||
Ok(master_card_id)
|
||||
}
|
||||
|
||||
// Per-account storage quota over this account's cards AND characters. The
|
||||
// excluded ids are the entity being replaced by an in-place edit - its stored
|
||||
// size drops out and `adding` (the resulting size) replaces it
|
||||
fn check_quota(uid: i64, adding: i64, excluded_card_id: i64, excluded_character_id: i64) -> Result<(), String> {
|
||||
let used = database::owner_bytes(uid, excluded_card_id, excluded_character_id);
|
||||
if used + adding > MAX_BYTES_PER_USER {
|
||||
return Err(format!(
|
||||
"This upload would put your uploads at {} MB, over the {} MB per-account limit - delete a card or character first",
|
||||
(used + adding) / (1024 * 1024), MAX_BYTES_PER_USER / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Edit a card in place. The master_card_id - and everything derived from it:
|
||||
// master_skill_id, illust ids - stays the same, so a player who owns the card
|
||||
// keeps owning the same card. master_character_id is fixed too: repointing it
|
||||
@@ -1235,13 +1504,20 @@ pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(),
|
||||
if !can_manage(uid, owner) {
|
||||
return Err(String::from("You do not have permission to edit this card"));
|
||||
}
|
||||
let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
|
||||
let mut stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
|
||||
// An edit's "unchanged" fallbacks read from the stored blob, so the
|
||||
// stored reference itself is brought into the nerf band first - an
|
||||
// over-cap value the form leaves untouched stays over cap in the dry-run
|
||||
if nerf_active() {
|
||||
nerf_clamp_card(&mut stored);
|
||||
}
|
||||
let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0);
|
||||
|
||||
// Field validation first (cheap), art processing second - fail fast
|
||||
let mut card = build_card(master_card_id, master_character_id, fields, &stored)?;
|
||||
let card_art = collect_card_art(fields, false)?;
|
||||
card["art"] = merge_art(&stored["art"], &card_art);
|
||||
check_quota(owner, database::card_bytes(&card), master_card_id, 0)?;
|
||||
|
||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||
write_art(&card_dir(master_card_id), &card_art)?;
|
||||
@@ -1313,10 +1589,12 @@ pub fn create_character(uid: i64, fields: &Fields) -> Result<i64, String> {
|
||||
if !voice.is_empty() {
|
||||
character["voice"] = voice;
|
||||
}
|
||||
check_quota(uid, database::character_bytes(&character), 0, 0)?;
|
||||
|
||||
write_art(&character_dir(master_character_id), &character_art)?;
|
||||
write_voice(master_character_id, &voice_files)?;
|
||||
database::insert_character(master_character_id, uid, &character);
|
||||
database::insert_character(master_character_id, uid, &character)
|
||||
.map_err(|e| format!("Could not store the character: {}", e))?;
|
||||
database::bump_revision();
|
||||
drop(lock);
|
||||
|
||||
@@ -1340,6 +1618,7 @@ pub fn update_character(uid: i64, master_character_id: i64, fields: &Fields) ->
|
||||
if !voice.is_empty() {
|
||||
character["voice"] = voice.clone();
|
||||
}
|
||||
check_quota(owner, database::character_bytes(&character), 0, master_character_id)?;
|
||||
|
||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||
write_art(&character_dir(master_character_id), &character_art)?;
|
||||
@@ -1376,6 +1655,44 @@ pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Every card this account uploaded, gone - called from userdata::delete_account,
|
||||
// so a purged uploader leaves no catalog row resolving an owner id that no longer
|
||||
// exists (browse renders an uploader name for every row). Player copies of the
|
||||
// dead cards are wiped lazily on each account's next userdata pull, exactly like
|
||||
// an owner-initiated delete.
|
||||
//
|
||||
// Characters go with them only when nothing else references them: a custom
|
||||
// character that still backs SOMEONE ELSE'S card cannot be deleted without
|
||||
// serving a dangling master_character_id, which the client throws on. Those stay,
|
||||
// ownerless, which is the same trade delete_character already makes
|
||||
pub fn purge_owner(uid: i64) {
|
||||
if uid <= 0 {
|
||||
return;
|
||||
}
|
||||
if disabled() {
|
||||
return;
|
||||
}
|
||||
let cards = database::card_ids_for_owner(uid);
|
||||
let characters = database::character_ids_for_owner(uid);
|
||||
if cards.is_empty() && characters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||
for master_card_id in cards {
|
||||
database::delete_card(master_card_id);
|
||||
let _ = fs::remove_dir_all(card_dir(master_card_id));
|
||||
}
|
||||
for master_character_id in characters {
|
||||
if database::cards_using_character(master_character_id) > 0 {
|
||||
continue;
|
||||
}
|
||||
database::delete_character(master_character_id);
|
||||
let _ = fs::remove_dir_all(character_dir(master_character_id));
|
||||
}
|
||||
database::bump_revision();
|
||||
drop(lock);
|
||||
}
|
||||
|
||||
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||
if disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
@@ -1387,12 +1704,16 @@ async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||
Ok(fields) => fields,
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
match create_card(uid, &fields) {
|
||||
Ok(master_card_id) => send_json(object!{
|
||||
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
|
||||
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
|
||||
// that also has to keep serving the game API
|
||||
match web::block(move || create_card(uid, &fields)).await {
|
||||
Ok(Ok(master_card_id)) => send_json(object!{
|
||||
result: "OK",
|
||||
master_card_id: master_card_id
|
||||
}),
|
||||
Err(e) => webui::error(&e)
|
||||
Ok(Err(e)) => webui::error(&e),
|
||||
Err(_) => webui::error("The upload could not be processed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,12 +1729,16 @@ async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
let master_card_id = field_str(&fields, "master_card_id").parse::<i64>().unwrap_or(0);
|
||||
match update_card(uid, master_card_id, &fields) {
|
||||
Ok(()) => send_json(object!{
|
||||
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
|
||||
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
|
||||
// that also has to keep serving the game API
|
||||
match web::block(move || update_card(uid, master_card_id, &fields)).await {
|
||||
Ok(Ok(())) => send_json(object!{
|
||||
result: "OK",
|
||||
master_card_id: master_card_id
|
||||
}),
|
||||
Err(e) => webui::error(&e)
|
||||
Ok(Err(e)) => webui::error(&e),
|
||||
Err(_) => webui::error("The edit could not be processed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,12 +1786,16 @@ async fn character_create(req: HttpRequest, payload: Multipart) -> HttpResponse
|
||||
Ok(fields) => fields,
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
match create_character(uid, &fields) {
|
||||
Ok(master_character_id) => send_json(object!{
|
||||
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
|
||||
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
|
||||
// that also has to keep serving the game API
|
||||
match web::block(move || create_character(uid, &fields)).await {
|
||||
Ok(Ok(master_character_id)) => send_json(object!{
|
||||
result: "OK",
|
||||
master_character_id: master_character_id
|
||||
}),
|
||||
Err(e) => webui::error(&e)
|
||||
Ok(Err(e)) => webui::error(&e),
|
||||
Err(_) => webui::error("The upload could not be processed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1482,12 +1811,16 @@ async fn character_update(req: HttpRequest, payload: Multipart) -> HttpResponse
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
let master_character_id = field_str(&fields, "master_character_id").parse::<i64>().unwrap_or(0);
|
||||
match update_character(uid, master_character_id, &fields) {
|
||||
Ok(()) => send_json(object!{
|
||||
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
|
||||
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
|
||||
// that also has to keep serving the game API
|
||||
match web::block(move || update_character(uid, master_character_id, &fields)).await {
|
||||
Ok(Ok(())) => send_json(object!{
|
||||
result: "OK",
|
||||
master_character_id: master_character_id
|
||||
}),
|
||||
Err(e) => webui::error(&e)
|
||||
Ok(Err(e)) => webui::error(&e),
|
||||
Err(_) => webui::error("The edit could not be processed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,9 +1847,15 @@ async fn mine(req: HttpRequest) -> HttpResponse {
|
||||
let Some(uid) = get_session_uid(&req) else {
|
||||
return webui::error("Not logged in");
|
||||
};
|
||||
let mut cards = database::get_cards_by_owner(uid);
|
||||
if nerf_active() {
|
||||
for card in cards.members_mut() {
|
||||
nerf_clamp_card(card);
|
||||
}
|
||||
}
|
||||
send_json(object!{
|
||||
result: "OK",
|
||||
cards: database::get_cards_by_owner(uid),
|
||||
cards: cards,
|
||||
characters: database::get_characters_by_owner(uid)
|
||||
})
|
||||
}
|
||||
@@ -1532,6 +1871,9 @@ async fn browse(_req: HttpRequest) -> HttpResponse {
|
||||
for card in cards.members_mut() {
|
||||
card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone();
|
||||
card.remove("owner_id");
|
||||
if nerf_active() {
|
||||
nerf_clamp_card(card);
|
||||
}
|
||||
}
|
||||
let characters = database::get_characters_for_cards(0, &cards);
|
||||
send_json(object!{
|
||||
@@ -1813,7 +2155,7 @@ pub mod tests {
|
||||
let mut edit = Fields::new();
|
||||
edit.insert(String::from("voice_result_bond_1"), test_wav(31.0, 6));
|
||||
let err = with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit)).unwrap_err();
|
||||
assert!(err.contains("voice_result_bond_1") && err.contains("maximum is 30"), "{}", err);
|
||||
assert!(err.contains("voice_result_bond_1") && err.contains("30 second maximum"), "{}", err);
|
||||
let mut edit = Fields::new();
|
||||
edit.insert(String::from("voice_result_bond_1"), b"definitely not audio".to_vec());
|
||||
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
|
||||
@@ -1823,6 +2165,145 @@ pub mod tests {
|
||||
wipe(4010);
|
||||
}
|
||||
|
||||
// The home-screen moments ride the exact same upload path as the live/skill
|
||||
// ones: real create + edit, transcode, content addressing, renumbering.
|
||||
// day_1225 is the interesting one - the client turns it into a row carrying
|
||||
// a date_condition id rather than 0
|
||||
#[test]
|
||||
fn home_voicelines_upload_through_the_real_path() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(4011);
|
||||
|
||||
let mut fields = character_fields();
|
||||
fields.insert(String::from("voice_random_1"), test_wav(1.0, 11));
|
||||
field(&mut fields, "voice_random_1_text", "おはよう!");
|
||||
field(&mut fields, "voice_random_1_text_en", "Morning!");
|
||||
fields.insert(String::from("voice_random_3"), test_wav(1.0, 12));
|
||||
fields.insert(String::from("voice_touch_1"), test_wav(0.5, 13));
|
||||
fields.insert(String::from("voice_time_morning_1"), test_wav(0.5, 14));
|
||||
fields.insert(String::from("voice_day_1225_1"), test_wav(0.5, 15));
|
||||
fields.insert(String::from("voice_trigger_friend_1"), test_wav(0.5, 16));
|
||||
// Still ignored: a real moment, an index past the variant cap
|
||||
fields.insert(String::from("voice_random_10"), test_wav(1.0, 17));
|
||||
let id = with_permissions(4011, &[permissions::CARD_UPLOAD], || create_character(4011, &fields).unwrap());
|
||||
|
||||
let character = database::get_character(id).unwrap();
|
||||
let voice = &character["voice"];
|
||||
assert_eq!(voice.len(), 6);
|
||||
let kinds: Vec<String> = voice.members().map(|line| line["kind"].to_string()).collect();
|
||||
for kind in ["random", "touch", "time_morning", "day_1225", "trigger_friend"] {
|
||||
assert!(kinds.contains(&String::from(kind)), "{} missing from {:?}", kind, kinds);
|
||||
}
|
||||
// The sparse random indexes renumbered to 1 and 2, captions intact
|
||||
let random: Vec<&JsonValue> = voice.members().filter(|line| line["kind"] == "random").collect();
|
||||
assert_eq!(random.len(), 2);
|
||||
assert_eq!(random[0]["index"].as_i64(), Some(1));
|
||||
assert_eq!(random[1]["index"].as_i64(), Some(2));
|
||||
assert_eq!(random[0]["text"].as_str(), Some("おはよう!"));
|
||||
assert_eq!(random[0]["text_en"].as_str(), Some("Morning!"));
|
||||
for line in voice.members() {
|
||||
let md5 = line["md5"].to_string();
|
||||
let bytes = fs::read(voice_path(id, &md5)).unwrap();
|
||||
assert!(bytes.starts_with(b"OggS"), "transcoded to ogg");
|
||||
assert_eq!(database::find_voice_by_md5(&md5), Some(format!("characters/{}/voice/{}.ogg", id, md5)));
|
||||
}
|
||||
|
||||
// Editing one home moment leaves the others alone
|
||||
let touch_md5 = voice.members().find(|line| line["kind"] == "touch").unwrap()["md5"].to_string();
|
||||
let mut edit = Fields::new();
|
||||
field(&mut edit, "voice_day_1225_1_text", "メリークリスマス!");
|
||||
edit.insert(String::from("voice_touch_2"), test_wav(0.5, 18));
|
||||
with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit).unwrap());
|
||||
let after = database::get_character(id).unwrap();
|
||||
assert_eq!(after["voice"].len(), 7);
|
||||
assert_eq!(after["voice"].members().find(|line| line["kind"] == "day_1225").unwrap()["text"].as_str(),
|
||||
Some("メリークリスマス!"));
|
||||
assert_eq!(after["voice"].members()
|
||||
.find(|line| line["kind"] == "touch" && line["index"] == 1).unwrap()["md5"].to_string(), touch_md5);
|
||||
|
||||
// The 30-second and rich-text rules apply to the new moments too
|
||||
let mut edit = Fields::new();
|
||||
edit.insert(String::from("voice_time_night_1"), test_wav(31.0, 19));
|
||||
let err = with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit)).unwrap_err();
|
||||
assert!(err.contains("voice_time_night_1") && err.contains("30 second maximum"), "{}", err);
|
||||
let mut edit = Fields::new();
|
||||
field(&mut edit, "voice_touch_1_text", "<color=red>x");
|
||||
assert!(with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit))
|
||||
.unwrap_err().contains("<color>"));
|
||||
|
||||
wipe(4011);
|
||||
}
|
||||
|
||||
// A misspelled moment must not upload as silence-that-never-plays
|
||||
#[test]
|
||||
fn an_unknown_voice_kind_is_rejected() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(4012);
|
||||
|
||||
let reject = |key: &str| {
|
||||
let mut fields = character_fields();
|
||||
fields.insert(String::from(key), test_wav(0.5, 21));
|
||||
let err = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields)).unwrap_err();
|
||||
assert!(err.contains(key) && err.contains("not a voiceline field"), "{}: {}", key, err);
|
||||
};
|
||||
reject("voice_time_mornng_1");
|
||||
reject("voice_day_0102_1");
|
||||
reject("voice_live_start");
|
||||
reject("voice_random_x");
|
||||
|
||||
// Captions and delete flags go through the same check
|
||||
let mut fields = character_fields();
|
||||
field(&mut fields, "voice_seaon_spring_1_text", "x");
|
||||
let err = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields)).unwrap_err();
|
||||
assert!(err.contains("voice_seaon_spring_1_text"), "{}", err);
|
||||
|
||||
// ...and a correctly named one still goes through
|
||||
let mut fields = character_fields();
|
||||
fields.insert(String::from("voice_season_spring_1"), test_wav(0.5, 22));
|
||||
let id = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields).unwrap());
|
||||
assert_eq!(database::get_character(id).unwrap()["voice"][0]["kind"].as_str(), Some("season_spring"));
|
||||
|
||||
wipe(4012);
|
||||
}
|
||||
|
||||
// The webui renders whatever this serves; a kind that only exists in one of
|
||||
// the two lists is the bug this endpoint exists to prevent
|
||||
#[test]
|
||||
fn the_limits_endpoint_lists_the_voice_kinds() {
|
||||
let limits = upload_limits();
|
||||
assert_eq!(limits["max_voice_variants"].as_usize(), Some(MAX_VOICE_VARIANTS));
|
||||
assert_eq!(limits["max_voice_bytes"].as_usize(), Some(MAX_VOICE_BYTES));
|
||||
assert_eq!(limits["max_voice_seconds"].as_f64(), Some(MAX_VOICE_SECONDS));
|
||||
let served = &limits["voice_kinds"];
|
||||
assert_eq!(served.len(), VOICE_KINDS.len());
|
||||
for (entry, kind) in served.members().zip(VOICE_KINDS) {
|
||||
assert_eq!(entry["name"].as_str(), Some(kind.name));
|
||||
assert_eq!(entry["label"].as_str(), Some(kind.label));
|
||||
assert_eq!(entry["label_en"].as_str(), Some(kind.label_en));
|
||||
assert_eq!(entry["category"].as_str(), Some(kind.category));
|
||||
assert!(!kind.label.is_empty() && !kind.label_en.is_empty(), "{} needs both labels", kind.name);
|
||||
assert!(["home", "live", "result", "skill"].contains(&kind.category), "{}: {}", kind.name, kind.category);
|
||||
}
|
||||
// Names are the multipart infix and the client's switch arm: unique,
|
||||
// snake_case, and free of the separators the field parser splits on
|
||||
let mut names: Vec<&str> = VOICE_KINDS.iter().map(|kind| kind.name).collect();
|
||||
names.sort_unstable();
|
||||
let count = names.len();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), count, "duplicate voice kind name");
|
||||
for kind in VOICE_KINDS {
|
||||
assert!(kind.name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), "{}", kind.name);
|
||||
// The field parser strips these suffixes before splitting off the index
|
||||
assert!(!kind.name.ends_with("_text") && !kind.name.ends_with("_text_en") && !kind.name.ends_with("_delete"), "{}", kind.name);
|
||||
// ...and splits at the LAST underscore, so "{another kind}_{number}"
|
||||
// would be two readings of the same field name
|
||||
if let Some((head, tail)) = kind.name.rsplit_once('_') {
|
||||
assert!(tail.parse::<usize>().is_err() || !VOICE_KINDS.iter().any(|other| other.name == head),
|
||||
"{} is ambiguous with {}", kind.name, head);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic + sanity guard for the derived skill magnitude envelopes
|
||||
#[test]
|
||||
fn skill_ranges_derive_from_shipped_rows() {
|
||||
@@ -1846,6 +2327,8 @@ pub mod tests {
|
||||
assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range");
|
||||
let (_, value_max, _) = SKILL_VALUE_RANGES[&4];
|
||||
assert!(value_max < 100_000_000, "1e8 score-ups must be out of range");
|
||||
let (repeat_min, repeat_max, _) = SKILL_VALUE_RANGES[&11];
|
||||
assert_eq!((repeat_min, nerf_scale(repeat_max)), (1, 1));
|
||||
}
|
||||
|
||||
// A full create: derived ids, pinned columns, art md5s and the catalog
|
||||
@@ -2135,6 +2618,64 @@ pub mod tests {
|
||||
wipe(4004);
|
||||
}
|
||||
|
||||
// NERF_CUSTOM_CARDS: an over-cap upload is rejected, an over-cap value the
|
||||
// owner already stored is clamped in every served view (db row untouched),
|
||||
// and the skill ceiling drops to 4/5 of the official range
|
||||
#[test]
|
||||
fn nerf_band_caps_new_and_existing_cards() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(5005);
|
||||
crate::runtime::set_nerf_custom_cards(false);
|
||||
|
||||
// Un-nerfed: the official rarity ceiling (4110 for R) is reachable
|
||||
let fields = base_fields();
|
||||
let id = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &fields).unwrap());
|
||||
let mut over = base_fields();
|
||||
field(&mut over, "smile", "4110");
|
||||
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &over).unwrap());
|
||||
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
|
||||
|
||||
// Nerfed: a new 4110 upload is refused at the nerf band (2000 for R)...
|
||||
crate::runtime::set_nerf_custom_cards(true);
|
||||
let mut high = base_fields();
|
||||
field(&mut high, "smile", "4110");
|
||||
let err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &high)).unwrap_err();
|
||||
assert!(err.contains("smile must be between 1 and 2000 for a R card") && err.contains("nerfed"), "{}", err);
|
||||
// Boundary: 2000 accepted, 2001 refused. The fixture's trigger values
|
||||
// (25/24/24) sit in the official 13-30 but above the nerfed 13-24, so
|
||||
// a nerfed-boundary upload carries a trigger value legal in both bands
|
||||
let mut at_cap = base_fields();
|
||||
field(&mut at_cap, "smile", "2000");
|
||||
field(&mut at_cap, "skill_trigger_value", "20/20/20");
|
||||
let at_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &at_cap));
|
||||
assert!(at_cap_err.is_ok(), "2000 should be accepted: {:?}", at_cap_err.err());
|
||||
let mut over_cap = base_fields();
|
||||
field(&mut over_cap, "smile", "2001");
|
||||
let over_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &over_cap)).unwrap_err();
|
||||
assert!(over_cap_err.contains("must be between 1 and 2000"), "{}", over_cap_err);
|
||||
|
||||
// ...and the ALREADY-stored 4110 is clamped to the cap in the served view
|
||||
let mut view = database::get_card(id).unwrap();
|
||||
nerf_clamp_card(&mut view);
|
||||
assert_eq!(view["smile"].as_i64(), Some(2000));
|
||||
// while the db keeps the owner's stored value
|
||||
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
|
||||
|
||||
// Skill magnitudes: the ceiling is 4/5 of the official max (439 -> 351)
|
||||
crate::runtime::set_nerf_custom_cards(false);
|
||||
let mut strong = base_fields();
|
||||
field(&mut strong, "skill_effective_values", "439/439/439");
|
||||
field(&mut strong, "skill_trigger_value", "20/20/20");
|
||||
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong).unwrap());
|
||||
crate::runtime::set_nerf_custom_cards(true);
|
||||
let skill_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong)).unwrap_err();
|
||||
// 439 * 4/5 = 351: the official 91-439 range is capped at 351
|
||||
assert!(skill_err.contains("91-351"), "{}", skill_err);
|
||||
crate::runtime::set_nerf_custom_cards(false);
|
||||
|
||||
wipe(5005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_gates() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
@@ -2274,4 +2815,106 @@ pub mod tests {
|
||||
assert!(!viewer_can_resolve(100_010_001, 1));
|
||||
assert!(viewer_can_resolve(100_010_001, 2));
|
||||
}
|
||||
|
||||
// ---- defect-fix coverage -------------------------------------------------
|
||||
|
||||
// D14: card art lives at fixed per-card filenames that an in-place edit
|
||||
// overwrites, so the md5 index can briefly point at bytes that are no longer
|
||||
// its own. The client caches by md5 and never re-checks, so the route verifies
|
||||
// the bytes before it serves them
|
||||
#[test]
|
||||
fn the_data_route_refuses_bytes_that_do_not_match_the_md5() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(4090);
|
||||
|
||||
let png = seeded_png(120, 60, 91);
|
||||
let md5 = format!("{:x}", md5::compute(&png));
|
||||
let id = database::next_card_id();
|
||||
database::insert_card(id, 1001, 4090, &object!{
|
||||
"master_card_id": id,
|
||||
"rarity": 1,
|
||||
"art": [{ "kind": "c", "variant": "00", "md5": md5.clone(), "size": png.len() }]
|
||||
}, true, true).unwrap();
|
||||
fs::create_dir_all(card_dir(id)).unwrap();
|
||||
let file = format!("{}/c_00.png", card_dir(id));
|
||||
fs::write(&file, &png).unwrap();
|
||||
|
||||
let call = || -> actix_web::http::StatusCode {
|
||||
let req = actix_web::test::TestRequest::default()
|
||||
.param("hash", md5.clone())
|
||||
.param("file", format!("{}.png", md5))
|
||||
.to_http_request();
|
||||
actix_web::rt::System::new().block_on(async { data(req).await }).status()
|
||||
};
|
||||
assert_eq!(call(), actix_web::http::StatusCode::OK);
|
||||
|
||||
// The index still resolves, but the file no longer holds those bytes
|
||||
fs::write(&file, seeded_png(120, 60, 92)).unwrap();
|
||||
assert_eq!(call(), actix_web::http::StatusCode::NOT_FOUND);
|
||||
fs::write(&file, &png).unwrap();
|
||||
assert_eq!(call(), actix_web::http::StatusCode::OK);
|
||||
|
||||
wipe(4090);
|
||||
}
|
||||
|
||||
// D13: JsonValue::to_string renders Null as the literal "null", so a stored
|
||||
// line with no md5 used to aim the unlink at a file called null.ogg
|
||||
#[test]
|
||||
fn the_voice_gc_ignores_a_missing_md5() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
let character = 5_900_001;
|
||||
let dir = format!("{}/voice", character_dir(character));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let decoy = format!("{}/null.ogg", dir);
|
||||
fs::write(&decoy, b"not a voiceline").unwrap();
|
||||
let real_md5 = "b1".repeat(16);
|
||||
let real = voice_path(character, &real_md5);
|
||||
fs::write(&real, b"a voiceline").unwrap();
|
||||
|
||||
let old = array![
|
||||
{ "kind": "live_start", "index": 1, "md5": JsonValue::Null },
|
||||
{ "kind": "live_start", "index": 2, "md5": real_md5.clone() }
|
||||
];
|
||||
gc_voice(character, &old, &array![]);
|
||||
|
||||
assert!(fs::read(&decoy).is_ok(), "the GC unlinked a file named after JSON null");
|
||||
assert!(fs::read(&real).is_err(), "the dropped line's ogg was kept");
|
||||
let _ = fs::remove_dir_all(character_dir(character));
|
||||
}
|
||||
|
||||
// D15: cards and characters share one storage root, so they share one
|
||||
// per-account byte quota
|
||||
#[test]
|
||||
fn uploads_are_bounded_by_a_per_account_byte_quota() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
wipe(4091);
|
||||
assert_eq!(database::owner_bytes(4091, 0, 0), 0);
|
||||
|
||||
let id = database::next_card_id();
|
||||
database::insert_card(id, 1001, 4091, &object!{
|
||||
"master_card_id": id,
|
||||
"rarity": 1,
|
||||
"art": [{ "kind": "c", "variant": "00", "md5": "a1".repeat(16), "size": 1000 }]
|
||||
}, true, true).unwrap();
|
||||
let character = database::next_character_id();
|
||||
database::insert_character(character, 4091, &object!{
|
||||
"master_character_id": character,
|
||||
"name": "Quota",
|
||||
"art": [{ "kind": "icon", "md5": "a2".repeat(16), "size": 500 }],
|
||||
"voice": [{ "kind": "live_start", "index": 1, "md5": "a3".repeat(16), "size": 250 }]
|
||||
}).unwrap();
|
||||
|
||||
assert_eq!(database::owner_bytes(4091, 0, 0), 1750);
|
||||
// An in-place edit replaces its own bytes rather than adding to them
|
||||
assert_eq!(database::owner_bytes(4091, id, 0), 750);
|
||||
assert_eq!(database::owner_bytes(4091, 0, character), 1000);
|
||||
// Another account's uploads are not on this one's bill
|
||||
assert_eq!(database::owner_bytes(4092, 0, 0), 0);
|
||||
|
||||
assert!(check_quota(4091, 1, 0, 0).is_ok());
|
||||
assert!(check_quota(4091, MAX_BYTES_PER_USER, 0, 0).unwrap_err().contains("per-account limit"));
|
||||
|
||||
wipe(4091);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
|
||||
use jzon::{object, JsonValue};
|
||||
use crate::database::{custom_group, permissions};
|
||||
use crate::router::{userdata, webui, rich_text};
|
||||
use std::io::Cursor;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
|
||||
pub fn web_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/custom_group")
|
||||
.route("/list", web::get().to(list))
|
||||
.route("/create", web::post().to(create))
|
||||
.route("/{id}/logo", web::post().to(upload_logo))
|
||||
.route("/data/{hash}/{file}", web::get().to(logo_data)));
|
||||
}
|
||||
|
||||
fn can_manage(req: &HttpRequest) -> bool {
|
||||
webui::get_login_token(req)
|
||||
.and_then(|token| userdata::webui_login_token(&token))
|
||||
.and_then(|key| userdata::get_acc(&key)["user"]["id"].as_i64())
|
||||
.is_some_and(|id| permissions::has(id, permissions::GROUP_MANAGE))
|
||||
}
|
||||
|
||||
fn encode_logo(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format().map_err(|_| "Invalid image")?;
|
||||
if !matches!(reader.format(), Some(image::ImageFormat::Png | image::ImageFormat::Jpeg)) {
|
||||
return Err("Logo must be PNG or JPEG".into());
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let image = reader.decode().map_err(|_| "Invalid or oversized image")?.thumbnail(512, 256);
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image.write_to(&mut png, image::ImageFormat::Png).map_err(|_| "Could not encode logo")?;
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
async fn upload_logo(req: HttpRequest, id: web::Path<i64>, mut payload: web::Payload) -> HttpResponse {
|
||||
if !can_manage(&req) { return HttpResponse::Forbidden().finish(); }
|
||||
if !custom_group::exists(*id) { return HttpResponse::NotFound().finish(); }
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = payload.next().await {
|
||||
let Ok(chunk) = chunk else { return HttpResponse::BadRequest().finish(); };
|
||||
if bytes.len() + chunk.len() > 4 * 1024 * 1024 { return HttpResponse::PayloadTooLarge().finish(); }
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let png = match web::block(move || encode_logo(&bytes)).await {
|
||||
Ok(Ok(png)) => png,
|
||||
Ok(Err(message)) => return reply(object! { "result": "ERR", "message": message }),
|
||||
Err(_) => return HttpResponse::InternalServerError().finish(),
|
||||
};
|
||||
let hash = format!("{:x}", md5::compute(&png));
|
||||
let dir = crate::runtime::get_data_path("custom_groups");
|
||||
if store_logo(&dir, &hash, &png).is_err()
|
||||
|| custom_group::set_logo(*id, &hash, png.len() as i64).is_err() {
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
crate::database::custom_song::bump_revision();
|
||||
crate::database::custom_card::bump_revision();
|
||||
reply(object! { "result": "OK", "logo_md5": hash, "logo_size": png.len() })
|
||||
}
|
||||
|
||||
fn store_logo(dir: &str, hash: &str, png: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let destination = format!("{dir}/{hash}.png");
|
||||
if std::fs::read(&destination).is_ok_and(|bytes| bytes == png) { return Ok(()); }
|
||||
let temporary = format!("{dir}/{}.tmp", uuid::Uuid::now_v7());
|
||||
let result = std::fs::write(&temporary, png).and_then(|_| std::fs::rename(&temporary, destination));
|
||||
if result.is_err() { let _ = std::fs::remove_file(temporary); }
|
||||
result
|
||||
}
|
||||
|
||||
async fn logo_data(path: web::Path<(String, String)>) -> HttpResponse {
|
||||
let (hash, file) = path.into_inner();
|
||||
if hash.len() != 32 || !hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| file != format!("{hash}.png") || !custom_group::has_logo(&hash) {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
match std::fs::read(crate::runtime::get_data_path(&format!("custom_groups/{hash}.png"))) {
|
||||
Ok(bytes) if format!("{:x}", md5::compute(&bytes)) == hash => HttpResponse::Ok()
|
||||
.insert_header(("Cache-Control", "public, max-age=31536000, immutable"))
|
||||
.content_type("image/png").body(bytes),
|
||||
_ => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(value: JsonValue) -> HttpResponse {
|
||||
HttpResponse::Ok().insert_header(ContentType::json()).body(jzon::stringify(value))
|
||||
}
|
||||
|
||||
async fn list() -> HttpResponse {
|
||||
reply(object! { "result": "OK", "groups": custom_group::list() })
|
||||
}
|
||||
|
||||
async fn create(req: HttpRequest, body: String) -> HttpResponse {
|
||||
if !can_manage(&req) {
|
||||
return reply(object! { "result": "ERR", "message": "You do not have permission to create groups" });
|
||||
}
|
||||
let Ok(data) = jzon::parse(&body) else {
|
||||
return reply(object! { "result": "ERR", "message": "Invalid JSON" });
|
||||
};
|
||||
let name = data["name"].as_str().unwrap_or("").trim();
|
||||
let name_en = data["name_en"].as_str().unwrap_or("").trim();
|
||||
if name.is_empty() || name.chars().count() > 60 || name_en.chars().count() > 60 {
|
||||
return reply(object! { "result": "ERR", "message": "Group name must be 1-60 characters (English name at most 60)" });
|
||||
}
|
||||
if let Err(message) = rich_text::reject_tags("Group name", name, &[]) {
|
||||
return reply(object! { "result": "ERR", "message": message });
|
||||
}
|
||||
if let Err(message) = rich_text::reject_tags("English group name", name_en, &[]) {
|
||||
return reply(object! { "result": "ERR", "message": message });
|
||||
}
|
||||
match custom_group::create(name, name_en) {
|
||||
Ok(id) => reply(object! { "result": "OK", "id": id }),
|
||||
Err(_) => reply(object! { "result": "ERR", "message": "A group with that name already exists, or it could not be stored" })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logo_preserves_aspect_and_transparency() {
|
||||
let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(1024, 256, image::Rgba([1, 2, 3, 64])));
|
||||
let mut input = Cursor::new(Vec::new());
|
||||
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||
let output = encode_logo(input.get_ref()).unwrap();
|
||||
let decoded = image::load_from_memory(&output).unwrap().to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (512, 128));
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 64);
|
||||
assert!(encode_logo(b"not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_dimensions() {
|
||||
let source = image::DynamicImage::new_rgba8(4097, 1);
|
||||
let mut input = Cursor::new(Vec::new());
|
||||
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||
assert!(encode_logo(input.get_ref()).is_err());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn logo_upload_requires_group_permission() {
|
||||
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
|
||||
let request = actix_web::test::TestRequest::post().uri("/custom_group/10000/logo").set_payload("invalid").to_request();
|
||||
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn logo_catalog_and_content_addressed_download() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
let id = custom_group::create(&format!("Logo test {}", uuid::Uuid::now_v7()), "").unwrap();
|
||||
let mut input = Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgba8(32, 16).write_to(&mut input, image::ImageFormat::Png).unwrap();
|
||||
let png = encode_logo(input.get_ref()).unwrap();
|
||||
let hash = format!("{:x}", md5::compute(&png));
|
||||
let dir = crate::runtime::get_data_path("custom_groups");
|
||||
store_logo(&dir, &hash, &png).unwrap();
|
||||
store_logo(&dir, &hash, &png).unwrap();
|
||||
custom_group::set_logo(id, &hash, png.len() as i64).unwrap();
|
||||
let catalog = custom_group::list();
|
||||
let row = catalog.members().find(|g| g["id"].as_i64() == Some(id)).unwrap();
|
||||
assert_eq!(row["logo_md5"].as_str(), Some(hash.as_str()));
|
||||
assert_eq!(row["logo_size"].as_usize(), Some(png.len()));
|
||||
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
|
||||
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
|
||||
let response = actix_web::test::call_service(&app, request).await;
|
||||
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
|
||||
assert_eq!(actix_web::test::read_body(response).await.as_ref(), png);
|
||||
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/wrong.png")).to_request();
|
||||
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
|
||||
std::fs::write(format!("{dir}/{hash}.png"), b"corrupted").unwrap();
|
||||
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
|
||||
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder};
|
||||
|
||||
use super::{DEFAULT_PREVIEW_LENGTH_SEC, PREVIEW_FADE_SEC};
|
||||
use super::{DEFAULT_PREVIEW_LENGTH_SEC, MAX_AUDIO_SECONDS, PREVIEW_FADE_SEC};
|
||||
|
||||
// The whole audio pipeline runs in-process: symphonia (pure Rust) decodes and
|
||||
// validates uploads, vorbis_rs (libvorbis compiled into the binary - a library
|
||||
@@ -53,7 +53,12 @@ fn is_ogg_vorbis(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"OggS") && bytes.len() > 64 && bytes[..64].windows(7).any(|w| w == b"\x01vorbis")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
|
||||
// `max_duration_sec` is enforced INSIDE the packet loop, not after it: the decode
|
||||
// accumulates full planar f32 PCM, so checking the duration afterwards means the
|
||||
// allocation has already happened (an hour of 44.1kHz stereo is ~1.4GB of Vec).
|
||||
// Bailing at the first packet past the limit keeps the peak proportional to the
|
||||
// limit instead of to the upload
|
||||
fn decode(bytes: &[u8], max_duration_sec: f64) -> Result<DecodedAudio, String> {
|
||||
let stream = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.to_vec())), Default::default());
|
||||
let mut format = symphonia::default::get_probe()
|
||||
.probe(&Hint::new(), stream, Default::default(), Default::default())
|
||||
@@ -96,6 +101,9 @@ fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
|
||||
for (i, samples) in channels.iter_mut().enumerate() {
|
||||
samples.extend(interleaved.iter().skip(i).step_by(count));
|
||||
}
|
||||
if sample_rate > 0 && channels[0].len() as f64 / sample_rate as f64 > max_duration_sec {
|
||||
return Err(format!("Audio is over the {:.0} second maximum", max_duration_sec));
|
||||
}
|
||||
}
|
||||
|
||||
if channels.is_empty() || channels[0].is_empty() || sample_rate == 0 {
|
||||
@@ -141,14 +149,13 @@ fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
|
||||
// reads, keep it as-is when it's already ogg-vorbis, otherwise transcode. No
|
||||
// cuts, fades, loop points or preview split - mono or stereo as-sourced
|
||||
pub fn process_one_shot(bytes: &[u8], max_duration_sec: f64) -> Result<Cue, String> {
|
||||
let audio = decode(bytes)?;
|
||||
// The length limit belongs to the decoder, which stops at the first packet
|
||||
// past it rather than accumulating the whole clip and rejecting it afterwards
|
||||
let audio = decode(bytes, max_duration_sec)?;
|
||||
let duration = audio.duration();
|
||||
if duration < 0.2 {
|
||||
return Err(String::from("Audio clip is shorter than 0.2 seconds"));
|
||||
}
|
||||
if duration > max_duration_sec {
|
||||
return Err(format!("Audio clip is {:.1} seconds long - the maximum is {:.0} seconds", duration, max_duration_sec));
|
||||
}
|
||||
if is_ogg_vorbis(bytes) {
|
||||
return Ok(cue(bytes.to_vec(), duration));
|
||||
}
|
||||
@@ -160,7 +167,7 @@ pub fn process_one_shot(bytes: &[u8], max_duration_sec: f64) -> Result<Cue, Stri
|
||||
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
|
||||
// the client validates md5(file) against the value served in the catalog
|
||||
pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec: Option<f64>) -> Result<(Cue, Cue), String> {
|
||||
let audio = decode(bytes)?;
|
||||
let audio = decode(bytes, MAX_AUDIO_SECONDS)?;
|
||||
let duration = audio.duration();
|
||||
if duration <= 1.0 {
|
||||
return Err(String::from("Audio track is too short"));
|
||||
@@ -200,3 +207,54 @@ pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec:
|
||||
|
||||
Ok((play, select))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 44.1kHz 16-bit mono, `seconds` long
|
||||
fn wav(seconds: f64) -> Vec<u8> {
|
||||
let sample_rate: u32 = 44100;
|
||||
let frames = (seconds * sample_rate as f64) as u32;
|
||||
let data_len = frames * 2;
|
||||
let mut rv = Vec::new();
|
||||
rv.extend(b"RIFF");
|
||||
rv.extend((36 + data_len).to_le_bytes());
|
||||
rv.extend(b"WAVEfmt ");
|
||||
rv.extend(16u32.to_le_bytes());
|
||||
rv.extend(1u16.to_le_bytes());
|
||||
rv.extend(1u16.to_le_bytes());
|
||||
rv.extend(sample_rate.to_le_bytes());
|
||||
rv.extend((sample_rate * 2).to_le_bytes());
|
||||
rv.extend(2u16.to_le_bytes());
|
||||
rv.extend(16u16.to_le_bytes());
|
||||
rv.extend(b"data");
|
||||
rv.extend(data_len.to_le_bytes());
|
||||
for i in 0..frames {
|
||||
let sample = ((i as f64 * 440.0 * 2.0 * std::f64::consts::PI / sample_rate as f64).sin() * 8000.0) as i16;
|
||||
rv.extend(sample.to_le_bytes());
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
// The length limit is enforced from INSIDE the packet loop: the decode
|
||||
// accumulates full planar f32 PCM, so a post-decode check means the
|
||||
// allocation already happened (an hour of stereo is ~1.4GB of Vec)
|
||||
#[test]
|
||||
fn the_decoder_stops_at_the_duration_cap() {
|
||||
let three_seconds = wav(3.0);
|
||||
let audio = decode(&three_seconds, 5.0).unwrap();
|
||||
assert!((audio.duration() - 3.0).abs() < 0.01);
|
||||
|
||||
let Err(err) = decode(&three_seconds, 1.0) else {
|
||||
panic!("a three-second track decoded under a one-second cap");
|
||||
};
|
||||
assert!(err.contains("1 second maximum"), "{}", err);
|
||||
// The same cap is what refuses an over-long voiceline
|
||||
let Err(err) = process_one_shot(&three_seconds, 1.0) else {
|
||||
panic!("an over-long one-shot was accepted");
|
||||
};
|
||||
assert!(err.contains("1 second maximum"), "{}", err);
|
||||
assert!(process_one_shot(&three_seconds, 5.0).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use jzon::{object, JsonValue};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
|
||||
// into the SIF2 chart JSON the client deserializes into NoteData.
|
||||
@@ -8,17 +9,17 @@ use jzon::{object, JsonValue};
|
||||
// note_bomb_3 5, note_bomb_5 6, note_bomb_9 7, note_slide 11, note_slide_event 12,
|
||||
// note_slide_hold 13, with isHold(e) = e == 3 and isSlide(e) = e >= 11.
|
||||
//
|
||||
// SIF2 side: `type` only distinguishes an ordinary note (1) from a star/bomb note (3).
|
||||
// LiveTimeController.ToMarkerType accepts 1..3 and maps anything else to None; type 2 exists in
|
||||
// the enum but appears in ZERO of the 2146 shipped charts, so nothing emits it here.
|
||||
// SIF2 side: type 1 is a tap, 2 is a flick, and 3 is a skill/star note.
|
||||
// LiveTimeController.ToMarkerType accepts 1..3; stock charts happen not to
|
||||
// use type 2, but imported Bandori charts do.
|
||||
//
|
||||
// A SLIDE is structural, not a type. MarkerData derives it from the parent/child chain:
|
||||
// IsSliderMarker chained and the child is on a DIFFERENT line -> a slide segment
|
||||
// IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold
|
||||
// IsDistanceMarker has both a parent and a child -> a middle segment
|
||||
// So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes;
|
||||
// both are type 1. This is also how the client counts combo (NoteData.CalcMaxCombo: a note whose
|
||||
// child shares its line does not count, its tail does).
|
||||
// both are type 1. Combo follows LiveStatusSystem.PushInputResult, not the editor-only
|
||||
// NoteData.CalcMaxCombo helper, which undercounts repeated same-lane checkpoints.
|
||||
//
|
||||
// Mapping rules:
|
||||
// - line = position - 1 (both are right-to-left)
|
||||
@@ -32,16 +33,19 @@ use jzon::{object, JsonValue};
|
||||
// varies the blast width per effect; SIF2 has one bomb with one damage value, so the
|
||||
// four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped
|
||||
// here and the three wider ones arrived as ordinary taps.
|
||||
// - effect 11/12/13 (slide) -> CHAINED across lanes, all type 1. Slides sharing a notes_level
|
||||
// form one run: sorted by time and linked parent -> child, so each link crosses lanes and
|
||||
// the client sees a slider. A run ends on effect 13 (slide hold), whose synthesized
|
||||
// - effect 11/12/13/14 (slide) -> CHAINED across lanes, normally type 1. Slides sharing a notes_level
|
||||
// form one run: sorted by time and linked parent -> child. Cross-lane links
|
||||
// are sliders; same-lane links are hold segments. A run ends on effect 13 (slide hold), whose synthesized
|
||||
// same-line tail then makes it IsSliderLongMarker — the slide settling into a hold the
|
||||
// player releases. Verified against a real upload: every notes_level shared by more than
|
||||
// one note held exactly the slide-effect notes, each a monotonic sweep like
|
||||
// pos 9->8->7->6 with effects 11,11,11,13.
|
||||
// A lone slide with no chain partner stays a plain tap: a slider needs a cross-lane child.
|
||||
// - effect 0 (random) and anything else unknown -> plain type 1. Every effect the game
|
||||
// actually defines is covered above, so this is only a floor for hand-authored charts.
|
||||
// A lone slide with no chain partner stays a plain note. Same-lane links
|
||||
// become hold segments; effect 14 keeps the final point as a flick.
|
||||
// - effects 8/14 are custom-song extensions for a flick and a slide-end flick (type 2).
|
||||
// Effect 9 is a skill note (type 3); effect 10 is a hold with a flick tail.
|
||||
// The stock SIF1 vocabulary does not use 8/9/10/14.
|
||||
// - effect 0 (random) and anything else unknown -> plain type 1.
|
||||
// - notes_attribute is dropped (SIF2 has no per-note attribute). notes_level is consumed as
|
||||
// the chain id above and not emitted.
|
||||
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
|
||||
@@ -61,8 +65,8 @@ use jzon::{object, JsonValue};
|
||||
// ForceGroupId against the other chunk's GroupId and links the lane-closest pair).
|
||||
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
|
||||
// deserializes it verbatim.
|
||||
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
||||
// line (the game counts a same-lane hold as one combo for the chain)
|
||||
// - max_combo_count excludes a same-lane hold's root and a cross-lane slide settling
|
||||
// into a hold. Further same-lane checkpoints do count (see max_combo_count below).
|
||||
|
||||
// Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this.
|
||||
// Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to
|
||||
@@ -87,6 +91,14 @@ fn simultaneous(a: f64, b: f64) -> bool {
|
||||
const MISS_OFFSET_SEC: f64 = 0.15;
|
||||
const MISS_OFFSET_SLIDER_SEC: f64 = 0.34;
|
||||
|
||||
// The most notes one difficulty may carry. Every transcode step is linear in this
|
||||
// and the chart JSON is bounded only in bytes by the upload caps, so an explicit
|
||||
// ceiling is what keeps a 64MB chart from turning into tens of millions of
|
||||
// JsonValue allocations on a worker. The hardest shipped SIF2 chart is under 1500
|
||||
// notes and the whole 2146-chart official set peaks well below that, so this is
|
||||
// more than an order of magnitude of headroom
|
||||
pub const MAX_NOTES: usize = 20_000;
|
||||
|
||||
// MarkerData.IsSliderMarker: a chained note with a cross-lane parent or child.
|
||||
fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option<i64>) -> bool {
|
||||
let parent_id = data["parent_id"].as_i64().unwrap_or(0);
|
||||
@@ -106,10 +118,12 @@ fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option<i64>) -> bool {
|
||||
// m_MusicDuration is LiveMst._endWait + the music length, and _endWait is 0 in every one of the
|
||||
// 637 official live rows and in ours), so this is what has to fit inside the audio.
|
||||
pub fn end_time(chart: &JsonValue) -> f64 {
|
||||
let lines: Vec<(i64, i64)> = chart["notes"].members().skip(1)
|
||||
// Indexed, not scanned: this runs once per note over a chart whose note count
|
||||
// is only bounded by MAX_NOTES, and the linear lookup made it quadratic
|
||||
let lines: HashMap<i64, i64> = chart["notes"].members().skip(1)
|
||||
.map(|n| (n["id"].as_i64().unwrap_or(0), n["line"].as_i64().unwrap_or(0)))
|
||||
.collect();
|
||||
let line_of = |id: i64| lines.iter().find(|(i, _)| *i == id).map(|(_, line)| *line);
|
||||
let line_of = |id: i64| lines.get(&id).copied();
|
||||
|
||||
let mut end: f64 = 0.0;
|
||||
for data in chart["notes"].members().skip(1) {
|
||||
@@ -129,6 +143,42 @@ pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
|
||||
}))
|
||||
}
|
||||
|
||||
// LiveStatusSystem.PushInputResult skips combo for a non-slider long root or
|
||||
// IsSliderLongMarker. LiveInputResultControl maps those results to their child.
|
||||
// All other points count, including repeated checkpoints in a stationary slide.
|
||||
// Validate the links before repairing stored data; never guess a broken chart's count.
|
||||
pub fn max_combo_count(chart: &JsonValue) -> Option<i64> {
|
||||
let notes = &chart["notes"];
|
||||
if !notes.is_array() || notes.len() < 2 || notes[0]["id"] != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut by_id = HashMap::new();
|
||||
for note in notes.members().skip(1) {
|
||||
let id = note["id"].as_i64()?;
|
||||
let line = note["line"].as_i64()?;
|
||||
if id <= 0 || !(0..9).contains(&line) || by_id.insert(id, note).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
for note in notes.members().skip(1) {
|
||||
let id = note["id"].as_i64()?;
|
||||
let parent_id = note["parent_id"].as_i64()?;
|
||||
let child_id = note["child_id"].as_i64()?;
|
||||
let parent = if parent_id == 0 { None } else { Some(*by_id.get(&parent_id)?) };
|
||||
let child = if child_id == 0 { None } else { Some(*by_id.get(&child_id)?) };
|
||||
if parent_id == id || child_id == id
|
||||
|| parent.is_some_and(|p| p["child_id"] != id)
|
||||
|| child.is_some_and(|c| c["parent_id"] != id) {
|
||||
return None;
|
||||
}
|
||||
let hold_start = child.is_some_and(|c| c["line"] == note["line"])
|
||||
&& parent.is_none_or(|p| p["line"] != note["line"]);
|
||||
if !hold_start { count += 1; }
|
||||
}
|
||||
Some(count)
|
||||
}
|
||||
|
||||
struct WorkNote {
|
||||
time: f64,
|
||||
line: i64,
|
||||
@@ -142,7 +192,7 @@ struct WorkNote {
|
||||
// LiveModel.NoteEffect.isHold, widened to note_slide_hold: both carry a duration in
|
||||
// effect_value (notes.lua isTimeOver adds effect_value for note_hold and note_slide_hold alike).
|
||||
fn is_hold(effect: i64) -> bool {
|
||||
effect == 3 || effect == 13
|
||||
effect == 3 || effect == 10 || effect == 13
|
||||
}
|
||||
|
||||
// LiveModel.NoteEffect.isSlide
|
||||
@@ -171,6 +221,12 @@ fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64,
|
||||
if !(1..=9).contains(&position) {
|
||||
return Err(format!("Note {}: position {} is outside 1-9", index, position));
|
||||
}
|
||||
// NaN and the infinities pass every comparison below and then reach the
|
||||
// time-order sort, whose partial_cmp().unwrap() panics on an incomparable
|
||||
// pair - a worker panic authored by the uploaded file
|
||||
if !timing.is_finite() || !effect_value.is_finite() {
|
||||
return Err(format!("Note {}: timing_sec/effect_value must be finite numbers", index));
|
||||
}
|
||||
if timing < 0.0 {
|
||||
return Err(format!("Note {}: negative timing_sec {}", index, timing));
|
||||
}
|
||||
@@ -186,24 +242,38 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
if !beatmap.is_array() || beatmap.is_empty() {
|
||||
return Err(String::from("Chart is not a JSON array of notes"));
|
||||
}
|
||||
if beatmap.len() > MAX_NOTES {
|
||||
return Err(format!("Chart has {} notes - the maximum is {}", beatmap.len(), MAX_NOTES));
|
||||
}
|
||||
|
||||
let mut work: Vec<WorkNote> = Vec::new();
|
||||
// Slide chain id -> the work indices in that chain, in input order
|
||||
let mut chains: Vec<(i64, Vec<usize>)> = Vec::new();
|
||||
// (timing bits, position) -> effect, for the duplicate check below. Indexed
|
||||
// rather than rescanned: the old scan-all-preceding-notes loop was quadratic
|
||||
// over an input whose size the upload caps only bound in bytes. The key uses
|
||||
// the raw f64 bits, which is exactly the equality the scan tested (both
|
||||
// infinities and NaN are already rejected in parse_sif_note, so bit equality
|
||||
// and value equality agree here)
|
||||
let mut seen: HashMap<(u64, i64), i64> = HashMap::new();
|
||||
for (i, data) in beatmap.members().enumerate() {
|
||||
let (timing, effect, effect_value, position, group) = parse_sif_note(data, i)?;
|
||||
|
||||
for other in beatmap.members().take(i) {
|
||||
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
|
||||
match seen.insert((timing.to_bits(), position), effect) {
|
||||
Some(other) if other != effect => {
|
||||
return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position));
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let head = work.len();
|
||||
work.push(WorkNote {
|
||||
time: timing,
|
||||
line: position - 1,
|
||||
kind: if is_bomb(effect) { 3 } else { 1 },
|
||||
// Effects 8/14 are the custom-song interchange's flick and
|
||||
// slide-end flick; 9 carries a Bandori skill note.
|
||||
kind: if effect == 8 || effect == 14 { 2 }
|
||||
else if effect == 9 || is_bomb(effect) { 3 } else { 1 },
|
||||
parent: None,
|
||||
child: None
|
||||
});
|
||||
@@ -221,7 +291,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
work.push(WorkNote {
|
||||
time: timing + effect_value,
|
||||
line: position - 1,
|
||||
kind: 1,
|
||||
kind: if effect == 10 { 2 } else { 1 },
|
||||
parent: Some(head),
|
||||
child: None
|
||||
});
|
||||
@@ -229,8 +299,8 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Link each slide chain in time order. Consecutive members sit on different lines, which is
|
||||
// exactly what makes SIF2 treat the run as a slider rather than a hold. A member that already
|
||||
// Link each slide chain in time order. A cross-lane link becomes a slider
|
||||
// and a same-lane link becomes a hold segment. A member that already
|
||||
// has a child is a slide-hold, i.e. the end of the run, so the chain stops there — its tail
|
||||
// stays its child and the cross-lane parent link makes it IsSliderLongMarker.
|
||||
for (_, members) in chains.iter() {
|
||||
@@ -245,10 +315,6 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
if work[a].child.is_some() || work[b].parent.is_some() {
|
||||
break;
|
||||
}
|
||||
if work[a].line == work[b].line {
|
||||
// Same lane would read as a hold, not a slide; skip the link rather than lie
|
||||
continue;
|
||||
}
|
||||
work[a].child = Some(b);
|
||||
work[b].parent = Some(a);
|
||||
}
|
||||
@@ -294,17 +360,9 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
|
||||
"force_sync_group_id": 0
|
||||
}];
|
||||
let mut max_combo_count = 0;
|
||||
for index in order.iter() {
|
||||
let note = &work[*index];
|
||||
|
||||
// NoteData.CalcMaxCombo: a note whose child is on the SAME line (a hold) does not count,
|
||||
// its tail does. A cross-lane child (a slide segment) counts normally.
|
||||
match note.child {
|
||||
Some(child) if work[child].line == note.line => {},
|
||||
_ => max_combo_count += 1
|
||||
}
|
||||
|
||||
notes.push(object!{
|
||||
"id": ids[*index],
|
||||
"num": nums[*index],
|
||||
@@ -319,120 +377,17 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
Ok((object!{
|
||||
let mut chart = object!{
|
||||
"max_lane": 9,
|
||||
"sound_name": "",
|
||||
"max_combo_count": max_combo_count,
|
||||
"max_combo_count": 0,
|
||||
"notes": notes
|
||||
}, max_combo_count))
|
||||
};
|
||||
let combo = max_combo_count(&chart).ok_or("Invalid transcoded note links")?;
|
||||
chart["max_combo_count"] = combo.into();
|
||||
Ok((chart, combo))
|
||||
}
|
||||
|
||||
// Regroups a STORED transcoded chart whose spawn groups predate the pairing rule above: the
|
||||
// old transcoder gave every note of an equal-time cluster one shared num (force_sync_group_id
|
||||
// always 0), and the client renders no head markers for a group of 3+ (see the header
|
||||
// comment). This rebuilds num / force_sync_group_id in place with the same clustering the
|
||||
// transcoder now uses — equal final time, lane-sorted, chunks of two, chained
|
||||
// force_sync_group_id — and re-points child_num at each child's renumbered spawn group.
|
||||
// Everything else (ids, times, lines, types, parent/child links, max_combo_count — combo
|
||||
// counting never depended on grouping) is untouched, so on a chart the current transcoder
|
||||
// produced this reproduces the stored bytes exactly.
|
||||
//
|
||||
// Returns false (chart untouched) unless some num is shared by MORE than two notes. That
|
||||
// makes it a safe no-op on current uploads AND on official-style encodings (whose num values
|
||||
// differ from ours — e.g. gaps of 3 — but whose groups never exceed two).
|
||||
pub fn regroup(chart: &mut JsonValue) -> bool {
|
||||
// (id, time, line) per real note; the dummy header at [0] stays untouched
|
||||
let notes: Vec<(i64, f64, i64)> = chart["notes"].members().skip(1).map(|n| (
|
||||
n["id"].as_i64().unwrap_or(0),
|
||||
n["time"].as_f64().unwrap_or(0.0),
|
||||
n["line"].as_i64().unwrap_or(0)
|
||||
)).collect();
|
||||
|
||||
// Only a pre-pairing chart (some num shared 3+ ways) is rewritten
|
||||
let mut group_sizes: Vec<(i64, i64)> = Vec::new();
|
||||
for data in chart["notes"].members().skip(1) {
|
||||
let num = data["num"].as_i64().unwrap_or(0);
|
||||
match group_sizes.iter_mut().find(|(n, _)| *n == num) {
|
||||
Some((_, count)) => *count += 1,
|
||||
None => group_sizes.push((num, 1))
|
||||
}
|
||||
}
|
||||
if group_sizes.iter().all(|(_, count)| *count <= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Time order; ids break ties (transcode issues them in time order, so this reproduces
|
||||
// the emission order the grouping pass originally saw)
|
||||
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||
order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0)));
|
||||
|
||||
// id -> (new num, new force_sync_group_id)
|
||||
let mut assigned: Vec<(i64, i64, i64)> = Vec::with_capacity(notes.len());
|
||||
let mut num = 100;
|
||||
let mut start = 0;
|
||||
while start < order.len() {
|
||||
let mut end = start + 1;
|
||||
while end < order.len() && simultaneous(notes[order[end]].1, notes[order[start]].1) {
|
||||
end += 1;
|
||||
}
|
||||
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
||||
cluster.sort_by_key(|index| notes[*index].2);
|
||||
let mut prev_num = 0;
|
||||
for chunk in cluster.chunks(2) {
|
||||
num += 1;
|
||||
for index in chunk {
|
||||
assigned.push((notes[*index].0, num, prev_num));
|
||||
}
|
||||
prev_num = num;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
let lookup = |id: i64| assigned.iter().find(|(i, _, _)| *i == id).map(|(_, n, f)| (*n, *f));
|
||||
|
||||
for data in chart["notes"].members_mut().skip(1) {
|
||||
let Some((new_num, force)) = lookup(data["id"].as_i64().unwrap_or(0)) else { continue; };
|
||||
data["num"] = new_num.into();
|
||||
data["force_sync_group_id"] = force.into();
|
||||
let child = data["child_id"].as_i64().unwrap_or(0);
|
||||
if child != 0 {
|
||||
// child_num names the child's spawn group and must follow its new num
|
||||
data["child_num"] = lookup(child).map(|(n, _)| n).unwrap_or(0).into();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// Test helper: fabricates what pre-pairing servers stored, by squashing a current chart back
|
||||
// to the OLD encoding — one shared num per equal-time cluster, force_sync_group_id 0, and
|
||||
// child_num following. Lives outside the tests module so the migration tests in
|
||||
// router/custom_song.rs can build realistic pre-fix fixtures from transcode output.
|
||||
#[cfg(test)]
|
||||
pub fn squash_to_pre_fix(chart: &mut JsonValue) {
|
||||
let notes: Vec<(i64, f64)> = chart["notes"].members().skip(1)
|
||||
.map(|n| (n["id"].as_i64().unwrap(), n["time"].as_f64().unwrap()))
|
||||
.collect();
|
||||
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||
order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0)));
|
||||
let mut nums: Vec<(i64, i64)> = Vec::new();
|
||||
let mut num = 100;
|
||||
let mut last_time = f64::NEG_INFINITY;
|
||||
for index in order {
|
||||
if notes[index].1 != last_time {
|
||||
num += 1;
|
||||
last_time = notes[index].1;
|
||||
}
|
||||
nums.push((notes[index].0, num));
|
||||
}
|
||||
let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0);
|
||||
for data in chart["notes"].members_mut().skip(1) {
|
||||
data["num"] = lookup(data["id"].as_i64().unwrap()).into();
|
||||
data["force_sync_group_id"] = 0.into();
|
||||
let child = data["child_id"].as_i64().unwrap_or(0);
|
||||
if child != 0 {
|
||||
data["child_num"] = lookup(child).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -802,6 +757,56 @@ mod tests {
|
||||
assert_eq!(combo, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_same_lane_checkpoints_count_like_the_client() {
|
||||
for final_effect in [11, 14] {
|
||||
let mut beatmap = jzon::array![];
|
||||
for i in 0..13 {
|
||||
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, 5,
|
||||
if i == 12 { final_effect } else { 11 }, 0.0, 500)).unwrap();
|
||||
}
|
||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||
// The head shares the first checkpoint's result; all twelve
|
||||
// non-root points still have their own runtime judgment.
|
||||
assert_eq!(combo, 12);
|
||||
assert_eq!(chart["max_combo_count"], 12);
|
||||
assert_eq!(chart["notes"].len(), 14);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_count_preserves_hold_and_cross_lane_rules() {
|
||||
for (positions, expected) in [
|
||||
(vec![5, 5], 1),
|
||||
(vec![5, 5, 5], 2),
|
||||
(vec![5, 5, 6], 2),
|
||||
(vec![4, 5, 5], 2),
|
||||
(vec![4, 5, 5, 5], 3),
|
||||
(vec![4, 5, 6], 3),
|
||||
] {
|
||||
let mut beatmap = jzon::array![];
|
||||
for (i, position) in positions.iter().enumerate() {
|
||||
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, *position, 11, 0.0, 500)).unwrap();
|
||||
}
|
||||
let (_, combo) = transcode(&beatmap).unwrap();
|
||||
assert_eq!(combo, expected, "chain {:?}", positions);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_repair_rejects_invalid_links() {
|
||||
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, 3, 1.0)]).unwrap();
|
||||
for (field, value) in [("id", 2), ("parent_id", 999), ("child_id", 999), ("child_id", 1)] {
|
||||
let mut broken = chart.clone();
|
||||
broken["notes"][1][field] = value.into();
|
||||
assert_eq!(max_combo_count(&broken), None, "invalid {}={}", field, value);
|
||||
}
|
||||
let mut broken = chart;
|
||||
broken["notes"][2]["parent_id"] = 0.into();
|
||||
assert_eq!(max_combo_count(&broken), None);
|
||||
assert_eq!(max_combo_count(&object!{}), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_hold_needs_a_duration() {
|
||||
// The effect 3 duration check has to cover note_slide_hold too
|
||||
@@ -839,13 +844,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn undefined_effects_fall_back_to_taps() {
|
||||
// Not part of NoteEffect; a hand-authored chart must still transcode to something valid
|
||||
for effect in [0, 8, 9, 10] {
|
||||
for effect in [0, 15] {
|
||||
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
|
||||
assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandori_flick_skill_and_slide_flick() {
|
||||
for (effect, kind) in [(8, 2), (9, 3), (14, 2)] {
|
||||
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
|
||||
assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect);
|
||||
}
|
||||
let (chart, combo) = transcode(&jzon::array![
|
||||
sif_slide(1.0, 7, 11, 0.0, 2),
|
||||
sif_slide(1.3, 5, 14, 0.0, 2)
|
||||
]).unwrap();
|
||||
assert_eq!(combo, 2);
|
||||
assert_eq!(chart["notes"][2]["type"], 2);
|
||||
assert_eq!(chart["notes"][1]["child_id"], chart["notes"][2]["id"].clone());
|
||||
|
||||
let (hold, combo) = transcode(&jzon::array![sif_note(1.0, 5, 10, 0.5)]).unwrap();
|
||||
assert_eq!(combo, 1);
|
||||
assert_eq!(hold["notes"][1]["type"], 1);
|
||||
assert_eq!(hold["notes"][2]["type"], 2);
|
||||
}
|
||||
|
||||
// Lifted verbatim from a chart uploaded to the live server (custom song 10008, "Edelied",
|
||||
// exported via /custom_song/download/10008) — a swipe run of note_slide into note_slide_hold,
|
||||
// which is the shape that used to arrive as undifferentiated taps. Note the editor writes a
|
||||
@@ -880,11 +904,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing
|
||||
// transcode output reproduces the old encoding exactly (one num per cluster, no force
|
||||
// links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included.
|
||||
// Parallel holds and a nine-lane wall must retain valid spawn groups.
|
||||
#[test]
|
||||
fn regroup_restores_pre_fix_wall_and_parallel_holds() {
|
||||
fn transcode_wall_and_parallel_holds() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0),
|
||||
sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0),
|
||||
@@ -893,23 +915,7 @@ mod tests {
|
||||
sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0),
|
||||
sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0)
|
||||
];
|
||||
let (expected, _) = transcode(&beatmap).unwrap();
|
||||
|
||||
let mut chart = expected.clone();
|
||||
squash_to_pre_fix(&mut chart);
|
||||
// Sanity: the squash really is the old encoding — whole clusters share one num
|
||||
assert_eq!(chart["notes"][1]["num"], 101);
|
||||
assert_eq!(chart["notes"][4]["num"], 101); // all 4 hold heads
|
||||
assert_eq!(chart["notes"][9]["num"], 103);
|
||||
assert_eq!(chart["notes"][17]["num"], 103); // all 9 wall notes
|
||||
assert_eq!(chart["notes"][1]["child_num"], 102, "squashed child_num must follow");
|
||||
for data in chart["notes"].members() {
|
||||
assert_eq!(data["force_sync_group_id"], 0);
|
||||
}
|
||||
|
||||
assert!(regroup(&mut chart), "a squashed chart must be rewritten");
|
||||
assert_eq!(jzon::stringify(chart.clone()), jzon::stringify(expected.clone()),
|
||||
"regroup must reproduce the current transcoder's output exactly");
|
||||
let (chart, _) = transcode(&beatmap).unwrap();
|
||||
|
||||
// Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the
|
||||
// previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111)
|
||||
@@ -936,37 +942,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regroup_is_a_no_op_on_current_encoding() {
|
||||
let beatmap = jzon::array![
|
||||
sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0), sif_note(1.0, 8, 1, 0.0),
|
||||
sif_note(2.0, 4, 3, 1.5)
|
||||
];
|
||||
let (chart, _) = transcode(&beatmap).unwrap();
|
||||
let before = jzon::stringify(chart.clone());
|
||||
let mut chart = chart;
|
||||
assert!(!regroup(&mut chart));
|
||||
assert_eq!(jzon::stringify(chart), before);
|
||||
}
|
||||
|
||||
// Official-shaped encodings (1132_5_Sn t=9.781: num gaps of 3, force_sync naming the other
|
||||
// pair) have groups of at most two and must never be "normalized" to our num sequence
|
||||
#[test]
|
||||
fn regroup_is_a_no_op_on_official_shaped_charts() {
|
||||
let mut chart = object!{
|
||||
"max_lane": 9, "sound_name": "", "max_combo_count": 4,
|
||||
"notes": [
|
||||
{"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||
{"id": 45, "num": 145, "line": 0, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||
{"id": 46, "num": 145, "line": 1, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||
{"id": 47, "num": 148, "line": 7, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145},
|
||||
{"id": 48, "num": 148, "line": 8, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145}
|
||||
]
|
||||
};
|
||||
let before = jzon::stringify(chart.clone());
|
||||
assert!(!regroup(&mut chart));
|
||||
assert_eq!(jzon::stringify(chart), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_charts() {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
use std::fs;
|
||||
|
||||
use super::{chart, database, song_path, asset_meta, LEVEL_COUNT};
|
||||
use crate::runtime::get_data_path;
|
||||
|
||||
// One-time, idempotent startup migration for charts transcoded before the spawn-group pairing
|
||||
// rule (chart.rs header): the old transcoder gave every note of an equal-time cluster ONE
|
||||
// shared num, and the client creates no head markers for a group of 3+ notes
|
||||
// (LiveMarkerControl.CreateMarkerUI plain-returns on count > 2), so 3+ simultaneous notes
|
||||
// were judged but never rendered. Stored transcoded charts are derived data with everything
|
||||
// the regroup needs (time + line per note), so they are rewritten in place — no original
|
||||
// upload required, which also covers songs from before export support.
|
||||
//
|
||||
// The same pass also corrects the stored catalog values that were fabricated rather than
|
||||
// derived from official masterdata (see below): the looping PLAY cue, the per-song score-rank
|
||||
// thresholds and the combo-mission targets.
|
||||
//
|
||||
// For each song directory with a catalog row, every chart with an over-shared num is
|
||||
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
|
||||
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
|
||||
// re-download the fixed chart on their next catalog sync. The revision is bumped ONCE if
|
||||
// anything changed. Charts the current transcoder produced (and official-shaped encodings)
|
||||
// are left byte-identical, so re-running every boot is free.
|
||||
pub fn run() {
|
||||
if super::disabled() {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(get_data_path("custom_songs")) else {
|
||||
// No custom_songs directory yet - nothing was ever uploaded
|
||||
return;
|
||||
};
|
||||
|
||||
let mut music_ids: Vec<i64> = entries.flatten()
|
||||
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<i64>().ok())
|
||||
.collect();
|
||||
music_ids.sort();
|
||||
|
||||
let mut songs_changed = 0;
|
||||
let mut charts_changed = 0;
|
||||
for music_id in music_ids {
|
||||
// A song directory without a catalog row is never served; leave it alone
|
||||
let Some(mut song) = database::get_song(music_id) else { continue; };
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// Catalogs written before cue_json took an is_loop argument marked BOTH cues as loop
|
||||
// cues. A looping PLAY cue never reports playback-end to the client, and the live's end
|
||||
// trigger (LiveTimeController: isMusicEnded -> InLiveDelay -> EndWait) hangs off exactly
|
||||
// that, so those lives never finish. Only the metadata changes - the ogg bytes and their
|
||||
// md5 are untouched, so no re-download is needed, just a catalog resync.
|
||||
if song["sound"]["play"]["is_loop"] == true {
|
||||
song["sound"]["play"]["is_loop"] = false.into();
|
||||
song["sound"]["play"]["loop_end_sec"] = 0.0.into();
|
||||
changed = true;
|
||||
println!("Custom song {}: play cue un-looped (pre-fix catalog)", music_id);
|
||||
}
|
||||
|
||||
// Score-rank thresholds and combo-mission targets used to be invented per song. Both are
|
||||
// objective masterdata: the official live rows all carry ONE score tuple, and every
|
||||
// live_mission_combo row is round(hardest full combo * 0.2/0.4/0.6/0.8) (see
|
||||
// default_scores / mission_combo). Recompute them the way an edit would, so songs
|
||||
// uploaded before the fix stop handing out rank S for free and stop asking for a literal
|
||||
// full combo on the fourth combo mission.
|
||||
let (score, multi_score) = super::default_scores();
|
||||
if song["score"] != score || song["multi_score"] != multi_score {
|
||||
song["score"] = score;
|
||||
song["multi_score"] = multi_score;
|
||||
changed = true;
|
||||
println!("Custom song {}: score rank thresholds reset to the official values", music_id);
|
||||
}
|
||||
// Same "hardest difficulty" rule as upload/edit: the last level entry, which both write
|
||||
// in ascending level order
|
||||
if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) {
|
||||
let missions = super::mission_combo(hardest);
|
||||
if song["mission_combo"] != missions {
|
||||
song["mission_combo"] = missions;
|
||||
changed = true;
|
||||
println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id);
|
||||
}
|
||||
}
|
||||
|
||||
for level in 1..=LEVEL_COUNT {
|
||||
let path = song_path(music_id, &format!("chart_{}.json", level));
|
||||
let Ok(bytes) = fs::read(&path) else { continue; };
|
||||
let Ok(mut chart_data) = jzon::parse(&String::from_utf8_lossy(&bytes)) else {
|
||||
println!("Custom song {} chart {}: not valid JSON, migration skipped", music_id, level);
|
||||
continue;
|
||||
};
|
||||
if !chart::regroup(&mut chart_data) {
|
||||
continue;
|
||||
}
|
||||
let new_bytes = jzon::stringify(chart_data);
|
||||
if let Err(e) = fs::write(&path, &new_bytes) {
|
||||
println!("Custom song {} chart {}: rewrite failed ({}), migration skipped", music_id, level, e);
|
||||
continue;
|
||||
}
|
||||
// The catalog md5/size must follow the served bytes or the client's
|
||||
// download-and-verify loop would never accept the asset
|
||||
let (md5, size) = asset_meta(new_bytes.as_bytes());
|
||||
for entry in song["levels"].members_mut() {
|
||||
if entry["level"] == level {
|
||||
entry["md5"] = md5.clone().into();
|
||||
entry["size"] = size.into();
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
charts_changed += 1;
|
||||
println!("Custom song {}: regrouped chart level {} (pre-pairing spawn groups)", music_id, level);
|
||||
}
|
||||
|
||||
if changed {
|
||||
database::update_song(music_id, &song);
|
||||
songs_changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if songs_changed > 0 {
|
||||
database::bump_revision();
|
||||
println!("Custom song spawn-group migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed);
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,40 @@ pub fn build(music_id: i64) -> Result<Vec<u8>, String> {
|
||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||
}
|
||||
|
||||
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||
let mut file = archive.by_name(name).ok()?;
|
||||
// Reads one entry, capped. Deflate's ceiling is about 1032:1, so an uncapped
|
||||
// read_to_end here is a zip bomb: a one-megabyte entry inflates to a gigabyte and
|
||||
// grows the Vec until the allocator or the OOM killer stops it. Every entry is
|
||||
// bounded by the upload form's own per-file cap, and by what is left of the
|
||||
// per-request budget across all entries.
|
||||
//
|
||||
// The central directory's declared size rejects the obvious case without
|
||||
// inflating anything; take(cap + 1) makes that declaration untrusted - a lying
|
||||
// header runs out of budget one byte past the cap and stops there.
|
||||
//
|
||||
// Ok(None) is "the package does not carry this entry", which is a normal outcome
|
||||
// for the optional ones
|
||||
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str, remaining: &mut usize) -> Result<Option<Vec<u8>>, String> {
|
||||
let Ok(mut file) = archive.by_name(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cap = std::cmp::min(super::MAX_FILE_BYTES, *remaining);
|
||||
// Which limit the entry actually ran into, so the message names the right one
|
||||
let too_big = if cap >= super::MAX_FILE_BYTES {
|
||||
super::over_file_limit(name)
|
||||
} else {
|
||||
super::over_request_limit()
|
||||
};
|
||||
if file.size() > cap as u64 {
|
||||
return Err(too_big);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
file.by_ref().take(cap as u64 + 1).read_to_end(&mut bytes)
|
||||
.map_err(|_| format!("Package entry '{}' could not be read", name))?;
|
||||
if bytes.len() > cap {
|
||||
return Err(too_big);
|
||||
}
|
||||
*remaining -= bytes.len();
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
// Expands a package into the same field map the upload form produces - the zip
|
||||
@@ -55,8 +84,10 @@ fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> O
|
||||
// visibility/shared_with/downloads_disabled aren't packaged and stay untouched
|
||||
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
|
||||
// The decompressed budget for the whole package, shared by every entry
|
||||
let mut remaining = super::MAX_REQUEST_BYTES;
|
||||
|
||||
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
|
||||
let manifest = read_entry(&mut archive, "manifest.json", &mut remaining)?.ok_or(String::from("Package has no manifest.json"))?;
|
||||
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
|
||||
if manifest["format"].as_i64() != Some(1) {
|
||||
return Err(String::from("Unsupported package format"));
|
||||
@@ -67,6 +98,9 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
|
||||
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
||||
}
|
||||
}
|
||||
// Group IDs are server-local. The package records the original ID for
|
||||
// reference, but only the destination upload form may choose its group;
|
||||
// copying the ID could silently select a different band's row there.
|
||||
for data in manifest["levels"].members() {
|
||||
let Some(level) = data["level"].as_i64() else { continue; };
|
||||
if !data["level_number"].is_null() {
|
||||
@@ -74,11 +108,11 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
|
||||
}
|
||||
}
|
||||
|
||||
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket").ok_or(String::from("Package has no jacket"))?);
|
||||
fields.insert(String::from("audio"), read_entry(&mut archive, "audio").ok_or(String::from("Package has no audio"))?);
|
||||
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket", &mut remaining)?.ok_or(String::from("Package has no jacket"))?);
|
||||
fields.insert(String::from("audio"), read_entry(&mut archive, "audio", &mut remaining)?.ok_or(String::from("Package has no audio"))?);
|
||||
let mut has_chart = false;
|
||||
for level in 1..=LEVEL_COUNT {
|
||||
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level)) {
|
||||
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level), &mut remaining)? {
|
||||
fields.insert(format!("chart_{}", level), chart);
|
||||
has_chart = true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
_id,_prefabName,_masterBgmId,_priority,_masterReleaseLabelId
|
||||
1,DefaultTitle1,21000001,0,1
|
||||
2,DefaultTitle2,21000001,0,1
|
||||
3,DefaultTitle3,21000001,0,1
|
||||
4,DefaultTitle4,21000001,0,1
|
||||
5,DefaultTitle5,21000001,0,1
|
||||
_id,_imageName,_logoX,_logoY,_masterBgmId,_priority,_masterReleaseLabelId
|
||||
1,titlescreen_0001,181,-112,21000001,0,1
|
||||
2,titlescreen_0002,-609.44,300.61,21000001,0,1
|
||||
3,titlescreen_0003,0,264,21000001,0,1
|
||||
4,titlescreen_0004,-343,-282.61,21000001,0,1
|
||||
5,titlescreen_0005,0,346,21000001,0,1
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
_id,_prefabName,_masterBgmId,_priority,_masterReleaseLabelId
|
||||
1,DefaultTitle1,21000001,0,1
|
||||
2,DefaultTitle2,21000001,0,1
|
||||
3,DefaultTitle3,21000001,0,1
|
||||
4,DefaultTitle4,21000001,0,1
|
||||
5,DefaultTitle5,21000001,0,1
|
||||
_id,_imageName,_logoX,_logoY,_masterBgmId,_priority,_masterReleaseLabelId
|
||||
1,titlescreen_0001,181,-112,21000001,0,1
|
||||
2,titlescreen_0002,-609.44,300.61,21000001,0,1
|
||||
3,titlescreen_0003,0,264,21000001,0,1
|
||||
4,titlescreen_0004,-343,-282.61,21000001,0,1
|
||||
5,titlescreen_0005,0,346,21000001,0,1
|
||||
|
||||
|
@@ -5676,9 +5676,17 @@
|
||||
"type": "uint"
|
||||
},
|
||||
{
|
||||
"name": "_prefabName",
|
||||
"name": "_imageName",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "_logoX",
|
||||
"type": "float"
|
||||
},
|
||||
{
|
||||
"name": "_logoY",
|
||||
"type": "float"
|
||||
},
|
||||
{
|
||||
"name": "_masterBgmId",
|
||||
"type": "uint"
|
||||
|
||||
@@ -198,6 +198,7 @@ fn init_star_event(event: &mut JsonValue) {
|
||||
|
||||
async fn event(Session { key, body }: Session) -> impl Responder {
|
||||
let master_event_id = body["master_event_id"].as_u32().unwrap();
|
||||
super::story::visit_event(&key, master_event_id);
|
||||
let mut event = get_event_data(&key, master_event_id);
|
||||
|
||||
let is_star_event = STAR_EVENT_IDS.contains(&master_event_id);
|
||||
|
||||
@@ -40,15 +40,41 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
|
||||
AssetVersion { region: "JP", platform: "Android", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "ed8f4b8df9d3935d689e1236a6816b2b", latest: false },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "407a8fd81cc5f525ad12c957dbefccb2", latest: false },
|
||||
|
||||
// Re-written client versions 2.4.0 -
|
||||
AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "73c12d3a4986013afa1eee65469124f7", latest: true },
|
||||
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d98640952f358554306658c2522d7f6", latest: true },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d7c80f2a8e45789c1f391879710bdbc", latest: true },
|
||||
// Re-written client versions 2.4.0 - 2.4.2
|
||||
AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "d1b4db937c1af8364d38f08296cbcfae", latest: false },
|
||||
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "8fcb61a08e69d854438c4f318d38cabd", latest: false },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "65bb39a0030620d9720edb5e65d31ac3", latest: false },
|
||||
|
||||
// Re-written client versions 2.5.0
|
||||
AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: false },
|
||||
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: false },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: false },
|
||||
|
||||
// Re-written client versions 2.6.0 - 2.6.1
|
||||
AssetVersion { region: "JP", platform: "Windows", version: "7c74b0df372a460d82378e55dc456260", hash: "8941d32738752c4ca932a28d866852ba", latest: false },
|
||||
AssetVersion { region: "JP", platform: "Android", version: "7c74b0df372a460d82378e55dc456260", hash: "f5ac6238570d8ff8351fda9c79e8774a", latest: false },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "7c74b0df372a460d82378e55dc456260", hash: "b77459c89ec94acc4fddc787ded62b95", latest: false },
|
||||
|
||||
// Re-written client versions 2.7.0 -
|
||||
AssetVersion { region: "JP", platform: "Windows", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4cbd26690102b6b98f5886263bdfefcd", latest: true },
|
||||
AssetVersion { region: "JP", platform: "Android", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4e64250bb32af39247fe7d7cdb840982", latest: true },
|
||||
AssetVersion { region: "JP", platform: "iOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "96615485374096183e4e0ce900671910", latest: true },
|
||||
|
||||
|
||||
AssetVersion { region: "JP", platform: "Linux", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
|
||||
AssetVersion { region: "JP", platform: "macOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
|
||||
|
||||
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
|
||||
];
|
||||
|
||||
impl AssetVersion {
|
||||
fn accepts_override(&self) -> bool {
|
||||
// The retired GL clients can still use a custom version/hash, but they are not
|
||||
// current releases. Keep override eligibility separate from update policy.
|
||||
self.latest || (self.region == "GL"
|
||||
&& self.version == "5260ff15dff8ba0c00ad91400f515f55")
|
||||
}
|
||||
|
||||
fn stock_hash(&self) -> String {
|
||||
if self.latest && self.platform == "Android" && get_easter_mode() {
|
||||
if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) {
|
||||
@@ -60,9 +86,15 @@ impl AssetVersion {
|
||||
|
||||
fn override_pair(&self) -> Option<(String, String)> {
|
||||
let args = crate::get_args();
|
||||
let ov = args.asset_version.as_str();
|
||||
let ov = if self.region == "GL" {
|
||||
args.en_asset_version.as_str()
|
||||
} else {
|
||||
args.asset_version.as_str()
|
||||
};
|
||||
let oh = match (self.region, self.platform) {
|
||||
("JP", "Windows") => args.windows_asset_hash.as_str(),
|
||||
("JP", "Linux") => args.linux_asset_hash.as_str(),
|
||||
("JP", "macOS") => args.macos_asset_hash.as_str(),
|
||||
("JP", "Android") => args.jp_android_asset_hash.as_str(),
|
||||
("JP", "iOS") => args.jp_ios_asset_hash.as_str(),
|
||||
("GL", "Android") => args.en_android_asset_hash.as_str(),
|
||||
@@ -79,15 +111,18 @@ impl AssetVersion {
|
||||
}
|
||||
|
||||
fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> {
|
||||
let args = crate::get_args();
|
||||
let mut out = Vec::new();
|
||||
for entry in ASSET_VERSIONS {
|
||||
if entry.platform != platform {
|
||||
continue;
|
||||
}
|
||||
if entry.version == asset_version {
|
||||
out.push(entry.stock_hash());
|
||||
if entry.version == asset_version && !entry.hash.is_empty() {
|
||||
if entry.latest || !args.force_updates {
|
||||
out.push(entry.stock_hash());
|
||||
}
|
||||
}
|
||||
if entry.latest {
|
||||
if entry.accepts_override() {
|
||||
if let Some((ov, oh)) = entry.override_pair() {
|
||||
if ov == asset_version {
|
||||
out.push(oh);
|
||||
@@ -104,14 +139,14 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
|
||||
if entry.platform != platform {
|
||||
continue;
|
||||
}
|
||||
if entry.latest {
|
||||
if entry.accepts_override() {
|
||||
if let Some((ov, oh)) = entry.override_pair() {
|
||||
if ov == asset_version {
|
||||
return Some(oh);
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.version == asset_version && stock.is_none() {
|
||||
if entry.version == asset_version && stock.is_none() && !entry.hash.is_empty() {
|
||||
stock = Some(entry.stock_hash());
|
||||
}
|
||||
}
|
||||
@@ -144,7 +179,7 @@ pub fn get_player_region(asset_version: &str) -> Option<String> {
|
||||
if entry.version == asset_version {
|
||||
return Some(entry.region.to_string());
|
||||
}
|
||||
if entry.latest {
|
||||
if entry.accepts_override() {
|
||||
if let Some((ov, _)) = entry.override_pair() {
|
||||
if ov == asset_version {
|
||||
return Some(entry.region.to_string());
|
||||
@@ -164,6 +199,8 @@ pub fn parse_platform(header: &str) -> &str {
|
||||
"iphone" => "iOS",
|
||||
"windows" => "Windows",
|
||||
"windowsplayer" => "Windows",
|
||||
"linuxplayer" => "Linux",
|
||||
"osxplayer" => "macOS",
|
||||
"webglplayer" => "WebGL",
|
||||
"editor" => "Editor",
|
||||
"windowseditor" => "Editor",
|
||||
@@ -387,6 +424,10 @@ pub fn send(mut data: JsonValue, uid: i64, req: &HttpRequest) -> HttpResponse {
|
||||
//println!("{}", jzon::stringify(data.clone()));
|
||||
data["server_time"] = set_time(data["server_time"].as_u64().unwrap_or(0), uid, true).into();
|
||||
|
||||
if data["data"].is_object() {
|
||||
super::mission::filter_response(&mut data["data"], req.headers());
|
||||
}
|
||||
|
||||
if !data["data"]["item_list"].is_empty() || !data["data"]["updated_value_list"]["item_list"].is_empty() {
|
||||
items::check_for_region(&mut data, req.headers());
|
||||
}
|
||||
@@ -466,3 +507,43 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod platform_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_version_overrides_keep_jp_and_global_distinct() {
|
||||
crate::runtime::apply_config_json(
|
||||
r#"{"assetVersion":"jp-test-version","enAssetVersion":"gl-test-version","jpAndroidAssetHash":"jp-test-hash","enAndroidAssetHash":"gl-test-hash"}"#,
|
||||
);
|
||||
|
||||
assert_eq!(get_player_region("jp-test-version").as_deref(), Some("JP"));
|
||||
assert_eq!(get_player_region("gl-test-version").as_deref(), Some("GL"));
|
||||
assert_eq!(preferred_hash("jp-test-version", "Android").as_deref(), Some("jp-test-hash"));
|
||||
assert_eq!(preferred_hash("gl-test-version", "Android").as_deref(), Some("gl-test-hash"));
|
||||
assert!(valid_hashes("gl-test-version", "Android").contains(&"gl-test-hash".to_string()));
|
||||
assert!(!ASSET_VERSIONS.iter().any(|entry| entry.region == "GL" && entry.latest));
|
||||
|
||||
crate::runtime::apply_config_json("{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_player_platforms_are_known() {
|
||||
assert_eq!(parse_platform("WindowsPlayer"), "Windows");
|
||||
assert_eq!(parse_platform("LinuxPlayer"), "Linux");
|
||||
assert_eq!(parse_platform("OSXPlayer"), "macOS");
|
||||
assert_eq!(parse_platform("OSXEditor"), "Editor");
|
||||
}
|
||||
|
||||
// Linux / macOS have no baked hash: without the CLI override the platform has no valid
|
||||
// hash at all (never an empty string presented as one)
|
||||
#[test]
|
||||
fn unhashed_platforms_answer_nothing_without_an_override() {
|
||||
let latest = ASSET_VERSIONS.iter().find(|e| e.latest && e.platform == "Windows").unwrap().version;
|
||||
assert_eq!(preferred_hash(latest, "macOS"), None);
|
||||
assert_eq!(preferred_hash(latest, "Linux"), None);
|
||||
assert!(valid_hashes(latest, "macOS").is_empty());
|
||||
assert!(preferred_hash(latest, "Windows").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
|
||||
|
||||
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
|
||||
let body = jzon::parse(&body).unwrap();
|
||||
let migration_code = body["migration_code"].to_string();
|
||||
let password = decrypt_transfer_password(&body["migration_password"].to_string());
|
||||
|
||||
let user = userdata::user::migration::get_acc_transfer(&body["migration_code"].to_string(), &password);
|
||||
let user = userdata::user::migration::get_acc_transfer(&migration_code, &password);
|
||||
|
||||
let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
|
||||
// The gree error envelope REQUIRES `code` and `message` on a failure. The old
|
||||
// {result:"ERR", messsage:"User Not Found"} shape had neither (and misspelt the key),
|
||||
// which breaks both native gamelibs:
|
||||
// * iOS: +[GGLError errorWithJson:] builds
|
||||
// @{NSLocalizedDescriptionKey: message} with message == nil ->
|
||||
// NSInvalidArgumentException "attempt to insert nil object from objects[0]" -> the app
|
||||
// hard-crashes the moment a wrong password / unknown code is entered on the new device.
|
||||
// (It also reads `code` as [nil integerValue] == 0 == SUCCESS, so a non-crashing build
|
||||
// would have run the success path on an empty payload.)
|
||||
// * managed: Shock.GGL.Payment.GGLVerifyMigrationCode.CanRetry(code) tests
|
||||
// code == 7004 (kGGLPErrorVerifyMigrationIncorrectPassword) to show the
|
||||
// "re-enter your password" dialog instead of the fatal error dialog.
|
||||
// 7002 = kGGLPErrorVerifyMigrationCodeNotExist, 7004 = incorrect password.
|
||||
let (code, message) = if userdata::user::migration::transfer_code_exists(&migration_code) {
|
||||
(7004, "Migration password is incorrect")
|
||||
} else {
|
||||
(7002, "Migration code does not exist")
|
||||
};
|
||||
object!{
|
||||
result: "ERR",
|
||||
messsage: "User Not Found"
|
||||
result: "NG",
|
||||
code: code,
|
||||
message: message
|
||||
}
|
||||
} else {
|
||||
let data_user = userdata::get_acc(&user["login_token"].to_string());
|
||||
@@ -304,6 +324,11 @@ async fn migration_password_register(req: HttpRequest, body: String) -> impl Res
|
||||
let pass = decrypt_transfer_password(&body["migration_password"].to_string());
|
||||
|
||||
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &pass);
|
||||
if !pass.is_empty() {
|
||||
let mut missions = userdata::get_acc_missions(&uid);
|
||||
super::beginner_mission::advance(25, None, 1, &mut missions);
|
||||
userdata::save_acc_missions(&uid, missions);
|
||||
}
|
||||
|
||||
let resp = object!{
|
||||
result: "OK"
|
||||
|
||||
@@ -23,6 +23,10 @@ async fn preset(Session { key, body }: Session) -> impl Responder {
|
||||
}
|
||||
userdata::save_acc_home(&key, user);
|
||||
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
super::beginner_mission::advance(61, None, 1, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
|
||||
Api(Some(array![]))
|
||||
}
|
||||
|
||||
@@ -84,17 +88,20 @@ lazy_static! {
|
||||
}
|
||||
|
||||
async fn home(Login(key): Login) -> impl Responder {
|
||||
let account = userdata::get_acc(&key);
|
||||
super::chat::refresh_birthday(&key, &account);
|
||||
let mut user = userdata::get_acc_home(&key);
|
||||
|
||||
check_gifts(&mut user);
|
||||
|
||||
let mut user_missions = userdata::get_acc_missions(&key);
|
||||
let clear = items::completed_daily_mission(1253003, &mut user_missions);
|
||||
let mut clear = super::beginner_mission::refresh_account(&account, &mut user_missions);
|
||||
for id in items::completed_daily_mission(1253003, &mut user_missions).members() {
|
||||
clear.push(id.clone()).unwrap();
|
||||
}
|
||||
userdata::save_acc_home(&key, user.clone());
|
||||
user["clear_mission_ids"] = clear;
|
||||
if !user["clear_mission_ids"].is_empty() {
|
||||
userdata::save_acc_missions(&key, user_missions.clone());
|
||||
}
|
||||
userdata::save_acc_missions(&key, user_missions.clone());
|
||||
|
||||
let daily_missions = array![1224003, 1253003, 1273009, 1273010, 1273011, 1273012];
|
||||
|
||||
@@ -124,8 +131,13 @@ async fn home(Login(key): Login) -> impl Responder {
|
||||
}
|
||||
user["home"]["unread_chat_count"] = chat_count.into();
|
||||
|
||||
//todo
|
||||
user["home"]["beginner_mission_complete"] = 1.into();
|
||||
let seen_at = user["home"]["announcement_seen_at"].as_i64().unwrap_or(0);
|
||||
let new_announcement = crate::database::announcements::latest_published_at() > seen_at;
|
||||
user["home"]["new_announcement_flag"] = (new_announcement as i32).into();
|
||||
|
||||
let (ready, complete) = super::beginner_mission::home_status(&user_missions);
|
||||
user["home"]["clear_beginner_mission_count"] = ready.into();
|
||||
user["home"]["beginner_mission_complete"] = (complete as i32).into();
|
||||
|
||||
Api(Some(user))
|
||||
}
|
||||
|
||||
@@ -422,17 +422,6 @@ pub fn update_mission_status(master_mission_id: i64, expire: u64, completed: boo
|
||||
None
|
||||
}
|
||||
|
||||
pub fn update_mission_status_multi(master_mission_id: JsonValue, expire: u64, completed: bool, claimed: bool, advance: i64, missions: &mut JsonValue) -> JsonValue {
|
||||
let mut rv = array![];
|
||||
for mission in master_mission_id.members() {
|
||||
let val = update_mission_status(mission.as_i64().unwrap(), expire, completed, claimed, advance, missions);
|
||||
if let Some(val2) = val {
|
||||
rv.push(val2).unwrap();
|
||||
}
|
||||
}
|
||||
rv
|
||||
}
|
||||
|
||||
pub fn get_mission_status(id: i64, missions: &JsonValue) -> JsonValue {
|
||||
for mission in missions.members() {
|
||||
if mission["master_mission_id"].as_i64().unwrap() == id {
|
||||
@@ -545,8 +534,8 @@ pub fn completed_daily_mission(id: i64, missions: &mut JsonValue) -> JsonValue {
|
||||
let mut rv = array![];
|
||||
if id == 1253003 {
|
||||
rv = advance_variable_mission(1153001, 1153019, 1, missions);
|
||||
if update_mission_status(1653001, 0, true, false, 1, missions).is_some() {
|
||||
rv.push(1653001).unwrap();
|
||||
for id in super::beginner_mission::advance(53, None, 1, missions).members() {
|
||||
rv.push(id.clone()).unwrap();
|
||||
}
|
||||
}
|
||||
for mission in missions.members_mut() {
|
||||
|
||||
@@ -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![]))
|
||||
}
|
||||
@@ -713,6 +727,17 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
|
||||
|
||||
//1273009, 1273010, 1273011, 1273012
|
||||
let mut cleared_missions = items::advance_variable_mission(1105001, 1105017, 1, &mut user_missions);
|
||||
for id in super::beginner_mission::advance(5, None, 1, &mut user_missions).members() {
|
||||
cleared_missions.push(id.clone()).unwrap();
|
||||
}
|
||||
if !skipped {
|
||||
let started = get_started_live(key, body).unwrap_or(JsonValue::Null);
|
||||
if started["is_omakase"] == 1 {
|
||||
for id in super::beginner_mission::advance(62, None, 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
|
||||
}
|
||||
let boost = started["live_boost"].as_i64().or(body["live_boost"].as_i64()).unwrap_or(0);
|
||||
for id in super::beginner_mission::advance(6, Some(boost), 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
|
||||
}
|
||||
if body["master_live_id"].to_string().len() > 1 {
|
||||
let id = body["master_live_id"].to_string().split("").collect::<Vec<_>>()[2].parse::<i64>().unwrap_or(0);
|
||||
if (1..=4).contains(&id) {
|
||||
@@ -808,6 +833,8 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
|
||||
|
||||
let deck_slot = get_end_live_deck_id(&key, &body).unwrap_or(body["deck_slot"].as_i32().unwrap_or(user["user"]["main_deck_slot"].as_i32().unwrap()));
|
||||
let characters = get_live_character_list(lp_used, deck_slot, &mut user, &mut user_missions, &mut cleared_missions, &mut chats);
|
||||
for id in super::beginner_mission::refresh(&user, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
|
||||
super::story::refresh(&user, &mut user_missions);
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
userdata::save_acc_home(&key, user2.clone());
|
||||
@@ -840,6 +867,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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +390,9 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
||||
}
|
||||
|
||||
add_draw_count(&mut user, lottery_id, price_number);
|
||||
for id in super::beginner_mission::advance(63, Some(lottery_id), price["count"].as_i64().unwrap_or(0), &mut missions).members() {
|
||||
cleared_missions.push(id.clone()).unwrap();
|
||||
}
|
||||
let mut new_count = get_draw_count(&user, lottery_id, price_number);
|
||||
if is_stepup(lottery_id) && price_number == 1 {
|
||||
new_count += 1;
|
||||
@@ -427,7 +430,7 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
||||
}
|
||||
|
||||
|
||||
|
||||
// tests!!!
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -447,18 +450,18 @@ mod tests {
|
||||
let mut r1_ids = Vec::new();
|
||||
for seed in 0..3 {
|
||||
let id = custom_card_db::next_card_id();
|
||||
custom_card_db::insert_card(id, 1001, 6001, &object!{ "master_card_id": id, "rarity": 1, "seed": seed }, true, true);
|
||||
custom_card_db::insert_card(id, 1001, 6001, &object!{ "master_card_id": id, "rarity": 1, "seed": seed }, true, true).unwrap();
|
||||
r1_ids.push(id);
|
||||
}
|
||||
let r2 = custom_card_db::next_card_id();
|
||||
custom_card_db::insert_card(r2, 1001, 6001, &object!{ "master_card_id": r2, "rarity": 2 }, true, true);
|
||||
custom_card_db::insert_card(r2, 1001, 6001, &object!{ "master_card_id": r2, "rarity": 2 }, true, true).unwrap();
|
||||
let r3 = custom_card_db::next_card_id();
|
||||
custom_card_db::insert_card(r3, 1001, 6001, &object!{ "master_card_id": r3, "rarity": 3 }, true, true);
|
||||
custom_card_db::insert_card(r3, 1001, 6001, &object!{ "master_card_id": r3, "rarity": 3 }, true, true).unwrap();
|
||||
// Draft / unobtainable cards must never come out of the pool
|
||||
let draft = custom_card_db::next_card_id();
|
||||
custom_card_db::insert_card(draft, 1001, 6001, &object!{ "master_card_id": draft, "rarity": 1 }, false, true);
|
||||
custom_card_db::insert_card(draft, 1001, 6001, &object!{ "master_card_id": draft, "rarity": 1 }, false, true).unwrap();
|
||||
let unobtainable = custom_card_db::next_card_id();
|
||||
custom_card_db::insert_card(unobtainable, 1001, 6001, &object!{ "master_card_id": unobtainable, "rarity": 1 }, true, false);
|
||||
custom_card_db::insert_card(unobtainable, 1001, 6001, &object!{ "master_card_id": unobtainable, "rarity": 1 }, true, false).unwrap();
|
||||
|
||||
let drawn = custom_banner_cards(11);
|
||||
assert_eq!(drawn.len(), 11);
|
||||
|
||||
@@ -3,6 +3,29 @@ use actix_web::{web, HttpRequest, Responder};
|
||||
|
||||
use crate::router::{global, userdata, items, databases, Login, Session, Api};
|
||||
|
||||
pub fn filter_response(data: &mut JsonValue, headers: &actix_web::http::header::HeaderMap) {
|
||||
if items::get_region(headers) { return; }
|
||||
lazy_static::lazy_static! {
|
||||
static ref GLOBAL_IDS: std::collections::HashSet<i64> =
|
||||
databases::csv::table(databases::csv::Region::En, "mission")
|
||||
.members().filter_map(|row| row["id"].as_i64()).collect();
|
||||
}
|
||||
// Only project the response. Shared JP/global accounts keep their progress.
|
||||
for field in ["mission_list", "clear_mission_ids"] {
|
||||
if !data[field].is_array() { continue; }
|
||||
for i in (0..data[field].len()).rev() {
|
||||
let id = if field == "mission_list" { data[field][i]["master_mission_id"].as_i64() }
|
||||
else { data[field][i].as_i64() };
|
||||
if !id.is_some_and(|id| GLOBAL_IDS.contains(&id)) {
|
||||
data[field].array_remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if data["updated_value_list"].is_object() {
|
||||
filter_response(&mut data["updated_value_list"], headers);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/mission")
|
||||
@@ -12,6 +35,161 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_web::test]
|
||||
async fn mission_endpoint_filters_global_wire_response_not_saved_account() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (uid, key) = userdata::starter::create("Regional missions test").unwrap();
|
||||
let mut user = userdata::get_acc(&key);
|
||||
user["tutorial_step"] = 999.into();
|
||||
userdata::save_acc(&key, user);
|
||||
for version in ["5260ff15dff8ba0c00ad91400f515f55", "4c921d2443335e574a82e04ec9ea243c"] {
|
||||
let request = actix_web::test::TestRequest::default()
|
||||
.insert_header(("aoharu-user-id", uid.to_string()))
|
||||
.insert_header(("aoharu-asset-version", version)).to_http_request();
|
||||
let response = mission(Login(key.clone())).await.respond_to(&request);
|
||||
let body = actix_web::body::to_bytes(response.into_body()).await.ok().unwrap();
|
||||
let plain = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
|
||||
let response = jzon::parse(&plain).unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
let missions = &response["data"]["mission_list"];
|
||||
assert!(missions.members().any(|m| m["master_mission_id"] == 1073001));
|
||||
assert_eq!(missions.members().any(|m| m["master_mission_id"] == 1073037),
|
||||
items::get_region(request.headers()));
|
||||
assert!(userdata::get_acc_missions(&key).members()
|
||||
.any(|m| m["master_mission_id"] == 1073037));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_missions_match_client_masterdata_without_losing_saved_progress() {
|
||||
let request = actix_web::test::TestRequest::default()
|
||||
.insert_header(("aoharu-asset-version", "5260ff15dff8ba0c00ad91400f515f55"))
|
||||
.to_http_request();
|
||||
assert!(!items::get_region(request.headers()));
|
||||
let mut saved = array![];
|
||||
super::super::story::refresh(&object! {live_list: []}, &mut saved);
|
||||
let original = saved.clone();
|
||||
let ids = databases::csv::table(databases::csv::Region::En, "mission");
|
||||
let known: std::collections::HashSet<_> = ids.members()
|
||||
.filter_map(|r| r["id"].as_i64()).collect();
|
||||
let absent: Vec<_> = saved.members().filter_map(|m| {
|
||||
let id = m["master_mission_id"].as_i64().unwrap();
|
||||
(!known.contains(&id)).then_some(id)
|
||||
}).collect();
|
||||
assert_eq!(absent.len(), 30);
|
||||
assert!(absent.iter().all(|id| (1073037..=1073066).contains(id)));
|
||||
let expected: JsonValue = saved.members().filter(|m|
|
||||
known.contains(&m["master_mission_id"].as_i64().unwrap()))
|
||||
.cloned().collect::<Vec<_>>().into();
|
||||
let mut response = object! {mission_list: saved.clone(),
|
||||
clear_mission_ids: [1073037, 1073001, 1073066],
|
||||
updated_value_list: {mission_list: saved.clone(), clear_mission_ids: [1073001, 1073037]},
|
||||
reward_list: [{type: 1, amount: 100}]};
|
||||
filter_response(&mut response, request.headers());
|
||||
assert_eq!(response["mission_list"], expected);
|
||||
assert_eq!(response["updated_value_list"]["mission_list"], expected);
|
||||
assert_eq!(response["clear_mission_ids"], array![1073001]);
|
||||
assert_eq!(response["updated_value_list"]["clear_mission_ids"], array![1073001]);
|
||||
assert_eq!(response["reward_list"], array![object! {type: 1, amount: 100}]);
|
||||
assert_eq!(saved, original);
|
||||
let mut unrelated = object! {item_list: [], value: 42};
|
||||
let before = unrelated.clone();
|
||||
filter_response(&mut unrelated, request.headers());
|
||||
assert_eq!(unrelated, before);
|
||||
let mut jp = object! {mission_list: saved};
|
||||
let before = jp.clone();
|
||||
filter_response(&mut jp, actix_web::test::TestRequest::default().to_http_request().headers());
|
||||
assert_eq!(jp, before);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn bond_title_claim_notifies_client_and_persists() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (uid, key) = userdata::starter::create("Bond title test").unwrap();
|
||||
let request = || actix_web::test::TestRequest::default()
|
||||
.insert_header(("aoharu-user-id", uid.to_string())).to_http_request();
|
||||
let initial_titles = userdata::get_acc(&key)["master_title_ids"].clone();
|
||||
userdata::save_acc_missions(&key, array![object! {
|
||||
master_mission_id: 1158015, status: 2, progress: 1500, expire_date_time: 0
|
||||
}]);
|
||||
|
||||
let response = receive(request(), Session {
|
||||
key: key.clone(), body: object! {master_mission_ids: [1158015, 1158015]}
|
||||
}).await.0.unwrap();
|
||||
assert_eq!(response["reward_list"].len(), 1);
|
||||
assert_eq!(response["reward_list"][0]["type"], 8);
|
||||
assert_eq!(response["reward_list"][0]["value"], 3000015);
|
||||
assert_eq!(response["updated_value_list"]["master_title_ids"], array![3000015]);
|
||||
assert!(response["updated_value_list"]["item_list"].is_null());
|
||||
let saved = userdata::get_acc(&key);
|
||||
assert!(saved["master_title_ids"].contains(3000015));
|
||||
for title in initial_titles.members() {
|
||||
assert!(saved["master_title_ids"].contains(title.as_i64().unwrap()));
|
||||
}
|
||||
|
||||
let replay = receive(request(), Session {
|
||||
key: key.clone(), body: object! {master_mission_ids: [1158015]}
|
||||
}).await.0.unwrap();
|
||||
assert!(replay["reward_list"].is_empty());
|
||||
assert!(replay["updated_value_list"]["master_title_ids"].is_null());
|
||||
assert_eq!(userdata::get_acc(&key), saved);
|
||||
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
items::update_mission_status(1158054, 0, true, false, 13500, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
let next = receive(request(), Session {
|
||||
key: key.clone(), body: object! {master_mission_ids: [1158054]}
|
||||
}).await.0.unwrap();
|
||||
assert_eq!(next["updated_value_list"]["master_title_ids"], array![3000054]);
|
||||
let saved = userdata::get_acc(&key);
|
||||
assert!(saved["master_title_ids"].contains(3000015));
|
||||
assert!(saved["master_title_ids"].contains(3000054));
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn beginner_claims_persist_rewards_and_reject_repeats() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (uid, key) = userdata::starter::create("Beginner test").unwrap();
|
||||
let request = || actix_web::test::TestRequest::default()
|
||||
.insert_header(("aoharu-user-id", uid.to_string())).to_http_request();
|
||||
let first = [1653001,1605001,1662001,1663001,1660001,1614001,1666001,1658001,1661001];
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
for id in first {
|
||||
super::super::beginner_mission::set_progress(id, i64::MAX, &mut missions);
|
||||
}
|
||||
super::super::beginner_mission::set_progress(1653002, 2, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
let blocked = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002]} }).await.0.unwrap();
|
||||
assert!(blocked["reward_list"].is_empty());
|
||||
let gem_before = userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap();
|
||||
let mut ids: Vec<i64> = first.into();
|
||||
ids.push(first[0]);
|
||||
let body = object! {master_mission_ids: ids};
|
||||
let response = receive(request(), Session { key: key.clone(), body: body.clone() }).await.0.unwrap();
|
||||
assert_eq!(response["mission_list"].len(), 9);
|
||||
assert_eq!(response["reward_list"].len(), 18);
|
||||
let bonuses: i64 = response["reward_list"].members().filter(|r| r["type"] == 1)
|
||||
.map(|r| r["amount"].as_i64().unwrap()).sum();
|
||||
assert_eq!(bonuses, 1000);
|
||||
assert_eq!(userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap(), gem_before + bonuses);
|
||||
let items = &response["updated_value_list"]["item_list"];
|
||||
let unique: std::collections::HashSet<_> = items.members().map(|i| i["master_item_id"].as_i64()).collect();
|
||||
assert_eq!(items.len(), unique.len());
|
||||
let saved = userdata::get_acc(&key);
|
||||
let replay = receive(request(), Session { key: key.clone(), body }).await.0.unwrap();
|
||||
assert!(replay["reward_list"].is_empty());
|
||||
assert_eq!(userdata::get_acc(&key), saved);
|
||||
let second = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002, 1605002]} }).await.0.unwrap();
|
||||
assert_eq!(second["mission_list"].len(), 1);
|
||||
assert_eq!(second["mission_list"][0]["master_mission_id"], 1653002);
|
||||
assert_eq!(second["mission_list"][0]["progress"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
const VARIABLE_MISSIONS: [[i64; 2]; 5] = [[1153001, 1153019], [1105001, 1105017], [1101001, 1101030], [1121001, 1121019], [1112001, 1112033]];
|
||||
|
||||
async fn mission(Login(key): Login) -> impl Responder {
|
||||
@@ -25,9 +203,10 @@ async fn mission(Login(key): Login) -> impl Responder {
|
||||
}
|
||||
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
if items::refresh_dailies(&mut missions, global::timestamp()) {
|
||||
userdata::save_acc_missions(&key, missions.clone());
|
||||
}
|
||||
items::refresh_dailies(&mut missions, global::timestamp());
|
||||
super::story::refresh(&user, &mut missions);
|
||||
super::beginner_mission::refresh_account(&user, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions.clone());
|
||||
|
||||
Api(Some(object!{
|
||||
"mission_list": missions
|
||||
@@ -39,6 +218,10 @@ async fn clear(Session { key, body }: Session) -> impl Responder {
|
||||
|
||||
let mut cleared = array![];
|
||||
for id in body["master_mission_ids"].members() {
|
||||
if super::beginner_mission::contains(id.as_i64().unwrap_or(0)) {
|
||||
let condition = databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64();
|
||||
if !matches!(condition, Some(26 | 65 | 66)) { continue; }
|
||||
}
|
||||
let mission = items::get_mission_status(id.as_i64().unwrap(), &missions);
|
||||
if mission.is_empty() || mission["status"].as_i32().unwrap_or(0) >= 2 {
|
||||
continue;
|
||||
@@ -54,7 +237,11 @@ async fn clear(Session { key, body }: Session) -> impl Responder {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||
static CLAIM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
async fn receive(req: HttpRequest, Session { key, body }: Session) -> Api {
|
||||
// Keep the read/grant/save sequence exclusive across HTTP workers.
|
||||
let _claim = crate::lock_onto_mutex!(CLAIM_LOCK);
|
||||
let uid = global::get_uid(req.headers());
|
||||
let now = global::timestamp();
|
||||
|
||||
@@ -66,9 +253,21 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
||||
let mut touched_gem = false;
|
||||
let mut touched_coin = false;
|
||||
let mut touched_items = array![];
|
||||
let mut touched_titles = std::collections::BTreeSet::new();
|
||||
let before = missions.clone();
|
||||
let mut requested: Vec<_> = body["master_mission_ids"].members().filter_map(|id| id.as_i64()).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
requested.retain(|id| seen.insert(*id));
|
||||
// Process beginner cells in sheet order so a single claim can unlock the next sheet.
|
||||
requested.sort_by_key(|id| super::beginner_mission::level(*id));
|
||||
|
||||
for mission in body["master_mission_ids"].members() {
|
||||
let mid = mission.as_i64().unwrap();
|
||||
for mid in requested {
|
||||
if items::get_mission_status(mid, &missions)["status"] != 2 {
|
||||
continue;
|
||||
}
|
||||
if super::beginner_mission::contains(mid) && !super::beginner_mission::claimable(mid, &missions) {
|
||||
continue;
|
||||
}
|
||||
let mission_info = databases::MISSION_LIST[mid.to_string()].clone();
|
||||
for master in databases::MISSION_REWARDS[mission_info["masterMissionRewardId"].to_string()].members() {
|
||||
let reward_type = master["type"].as_i64().unwrap();
|
||||
@@ -90,6 +289,7 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
||||
match reward_type {
|
||||
1 => touched_gem = true,
|
||||
4 => touched_coin = true,
|
||||
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
|
||||
_ => { touched_items.push(master["value"].clone()).unwrap(); }
|
||||
}
|
||||
}
|
||||
@@ -135,24 +335,45 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
||||
user_id: uid,
|
||||
master_mission_id: mid,
|
||||
status: 3,
|
||||
progress: if expire != 0 { m["progress"].clone() } else { JsonValue::Null },
|
||||
progress: if expire != 0 || super::beginner_mission::is_counter(mid) { m["progress"].clone() } else { JsonValue::Null },
|
||||
expire_date: if expire != 0 { global::format_datetime(expire).into() } else { JsonValue::Null },
|
||||
received_date: global::format_datetime(now)
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
for master in super::beginner_mission::new_rewards(&before, &missions) {
|
||||
let reward_type = master["type"].as_i64().unwrap();
|
||||
rewards.push(object! {
|
||||
give_type: master["giveType"].clone(), type: master["type"].clone(),
|
||||
value: master["value"].clone(), level: master["level"].clone(), amount: master["amount"].clone()
|
||||
}).unwrap();
|
||||
items::give_gift(&object! {
|
||||
reward_type: reward_type, value: master["value"].clone(), amount: master["amount"].clone()
|
||||
}, &mut user, &mut missions, &mut array![], &mut chats);
|
||||
match reward_type {
|
||||
1 => touched_gem = true,
|
||||
4 => touched_coin = true,
|
||||
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
|
||||
_ => { touched_items.push(master["value"].clone()).unwrap(); }
|
||||
}
|
||||
}
|
||||
userdata::save_acc(&key, user.clone());
|
||||
userdata::save_acc_chats(&key, chats);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
|
||||
let mut updated_value_list = object!{};
|
||||
if !touched_titles.is_empty() {
|
||||
updated_value_list["master_title_ids"] = touched_titles.into_iter().collect::<Vec<_>>().into();
|
||||
}
|
||||
if touched_gem {
|
||||
updated_value_list["gem"] = user["gem"].clone();
|
||||
}
|
||||
if !touched_items.is_empty() {
|
||||
let mut item_list = array![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in touched_items.members() {
|
||||
if !seen.insert(id.as_i64()) { continue; }
|
||||
for item in user["item_list"].members() {
|
||||
if item["master_item_id"] == *id {
|
||||
item_list.push(item.clone()).unwrap();
|
||||
@@ -176,6 +397,9 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
|
||||
updated_value_list["point_list"] = point_list;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<_> = mission_list.members().cloned().collect();
|
||||
sorted.sort_by_key(|m| m["master_mission_id"].as_i64());
|
||||
let mission_list: JsonValue = sorted.into();
|
||||
Api(Some(object!{
|
||||
"reward_list": rewards,
|
||||
"gift_list": array![],
|
||||
|
||||
@@ -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)
|
||||
@@ -289,10 +289,8 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
|
||||
return Api(None);
|
||||
}
|
||||
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
|
||||
// party members post. Nothing about STARTING a live depends on which room it came
|
||||
// from — the one thing read out of it is the room's privacy, and that is read here
|
||||
// rather than at /multi_live/end because here the room is certainly still alive (see
|
||||
// record_room_privacy). Reconciling the party itself — checking the four results
|
||||
// party members post. It is recorded verbatim on the start record and nothing reads
|
||||
// anything else out of it. Reconciling the party itself — checking the four results
|
||||
// against each other — is still deferred.
|
||||
// master_event_id is recorded verbatim and never validated here: multi is a permanent
|
||||
// feature entered against a closed event (see scoring_event), so a closed — or absent
|
||||
@@ -329,7 +327,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
|
||||
userdata::save_acc(&key, user);
|
||||
|
||||
body["use_lp"] = consumed.into();
|
||||
record_room_privacy(&mut body);
|
||||
live::start_live(&key, &body);
|
||||
|
||||
Api(Some(object!{
|
||||
@@ -337,51 +334,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
|
||||
}))
|
||||
}
|
||||
|
||||
// Whether this live was played in a private (join-by-code) party, which is the one thing
|
||||
// /multi_live/end reads the room `token` for.
|
||||
//
|
||||
// Officially a multi live never updated the score board at all — the client says so on the
|
||||
// result screen. That stays true for PUBLIC matchmaking; a private party of friends is a
|
||||
// deliberate divergence and keeps its scores. The room is looked up in the relay's live
|
||||
// registry, which sits in this same process, so nothing new travels on the wire.
|
||||
//
|
||||
// Note the privacy answer is the room's CREATION-time one. The current visible/open flags
|
||||
// cannot be used: the game hides a public room too, as soon as its members are decided
|
||||
// (MultiMatchingView.SetRoomOpenVisible(false)), so every room in a live looks unlisted.
|
||||
fn is_private_party(body: &JsonValue) -> bool {
|
||||
rooms::token_is_private_room(body["token"].as_str().unwrap_or_default())
|
||||
}
|
||||
|
||||
// The key the answer above is stashed under on the start record.
|
||||
const RECORDED_PRIVATE: &str = "room_private";
|
||||
|
||||
// Asked once, at /multi_live/start, and written onto the payload start_live records.
|
||||
//
|
||||
// Asking at /multi_live/end instead made the answer depend on WHEN each of the up to four
|
||||
// party members got its POST in: the room dies with its last clean leave, so an ender that
|
||||
// posted after the party broke up saw no room and fell back to "public" while the others
|
||||
// had already been told "private" — the same live scored differently per player, and a
|
||||
// private party silently lost its score board. At start time the room is by definition
|
||||
// alive (its master minted the token moments earlier and every member is still seated), so
|
||||
// every member records the same flag off the same room.
|
||||
//
|
||||
// A registry miss records nothing at all rather than `false`: the end-side lookup is kept
|
||||
// as the fallback for records written before this existed, and "absent" is what selects it.
|
||||
fn record_room_privacy(body: &mut JsonValue) {
|
||||
if let Some(private) = rooms::token_room_privacy(body["token"].as_str().unwrap_or_default()) {
|
||||
body[RECORDED_PRIVATE] = private.into();
|
||||
}
|
||||
}
|
||||
|
||||
// What /multi_live/end obeys: the flag recorded at start when there is one, and the live
|
||||
// registry lookup only for a record that predates it.
|
||||
fn recorded_privacy(started: Option<&JsonValue>, body: &JsonValue) -> bool {
|
||||
match started.and_then(|s| s[RECORDED_PRIVATE].as_bool()) {
|
||||
Some(private) => private,
|
||||
None => is_private_party(body)
|
||||
}
|
||||
}
|
||||
|
||||
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||
// Older clients speak an incompatible multi — refuse before touching any state
|
||||
// (in particular before the start record can be consumed).
|
||||
@@ -452,27 +404,19 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder
|
||||
// live_end scales exp / gold / bond / mission rewards off.
|
||||
let mut end_body = body.clone();
|
||||
end_body["use_lp"] = lp_used.into();
|
||||
end_body["live_boost"] = live_boost.into();
|
||||
if end_body["deck_slot"].is_null() {
|
||||
if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) {
|
||||
end_body["deck_slot"] = slot.into();
|
||||
}
|
||||
}
|
||||
|
||||
// A public room scores like the official server did: no high score, no score board.
|
||||
// Read off the start record, so every member of one party gets the same answer no
|
||||
// matter how late its POST lands. Only a record from before that was recorded falls
|
||||
// back to a live lookup, where a room the registry no longer knows about (torn down,
|
||||
// or a restart since the live started) is treated as public — the behaviour to be
|
||||
// wrong on.
|
||||
let private = recorded_privacy(started, &body);
|
||||
println!(
|
||||
"multi_live/end: uid {} room is {} — score board {}",
|
||||
uid,
|
||||
if private { "PRIVATE" } else { "PUBLIC/unknown" },
|
||||
if private { "updated" } else { "skipped (official)" }
|
||||
);
|
||||
|
||||
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, private);
|
||||
// A multi live scores like the official server did: no high score, no score board —
|
||||
// the client's own result screen says so. Private (join-by-code) parties are no
|
||||
// exception (Ethan 2026-08-12; an earlier build recorded private-party scores, and
|
||||
// the room-privacy plumbing that told the two apart left with that behaviour). The
|
||||
// clear count and max combo still record either way — the live really was played.
|
||||
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, false);
|
||||
|
||||
rv["is_penalty_miss_ratio"] = status.into();
|
||||
// Fields RecvMultiLiveEndRData declares that live_end does not emit.
|
||||
@@ -837,166 +781,6 @@ mod tests {
|
||||
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
|
||||
}
|
||||
|
||||
// --- private vs public rooms (the score-board branch) --------------------------
|
||||
//
|
||||
// The relay runs in this process, so the end handler can ask the room registry what
|
||||
// kind of room a finishing live belongs to. These drive the REAL (global) registry
|
||||
// through the same entry points ws.rs uses, then ask is_private_party exactly what the
|
||||
// handler asks it. Room names and tokens are unique per test, so they do not collide
|
||||
// with each other in the shared registry.
|
||||
|
||||
fn open_room(name: &str, visible: bool, token: &str) -> rooms::ConnId {
|
||||
// The receiver is dropped immediately: nothing here reads the relay's outbound
|
||||
// frames, and a send to a gone writer is already a no-op (Registry::send).
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut reg = rooms::registry();
|
||||
let id = reg.connect(1, tx, Instant::now());
|
||||
reg.handle(id, ClientMsg::CreateRoom {
|
||||
name: name.to_string(),
|
||||
max_players: 4,
|
||||
visible,
|
||||
open: true,
|
||||
props: Map::new(),
|
||||
lobby_prop_keys: vec![],
|
||||
player_props: Map::new(),
|
||||
}, Instant::now());
|
||||
// MultiEventMatchingScene mints the token and pushes the room bag just before the
|
||||
// live starts; it is the same string the client then POSTs to /multi_live/end.
|
||||
reg.handle(id, ClientMsg::SetRoomProps {
|
||||
props: Map::from_pairs(vec![("C", Value::Str(token.to_string()))]),
|
||||
}, Instant::now());
|
||||
id
|
||||
}
|
||||
|
||||
fn set_room_props(id: rooms::ConnId, props: Vec<(&str, Value)>) {
|
||||
rooms::registry().handle(id, ClientMsg::SetRoomProps { props: Map::from_pairs(props) }, Instant::now());
|
||||
}
|
||||
|
||||
// A clean leave by the last active player destroys the room, exactly as a party
|
||||
// breaking up after the results does.
|
||||
fn close_room(id: rooms::ConnId) {
|
||||
rooms::registry().handle(id, ClientMsg::LeaveRoom, Instant::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_party_is_recognised_by_its_room_token() {
|
||||
let room = open_room("042719", false, "1.private-party");
|
||||
assert!(is_private_party(&object!{ token: "1.private-party" }));
|
||||
close_room(room);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_room_stays_public_once_the_game_hides_it() {
|
||||
// The whole reason privacy is latched at creation: MultiMatchingView closes and
|
||||
// hides a PUBLIC room the moment its members are decided, so mid-live its flags are
|
||||
// indistinguishable from a private room's.
|
||||
let room = open_room("1234567", true, "2.public-room");
|
||||
assert!(!is_private_party(&object!{ token: "2.public-room" }));
|
||||
|
||||
set_room_props(room, vec![("#253", Value::Bool(false)), ("#254", Value::Bool(false))]);
|
||||
assert!(
|
||||
!is_private_party(&object!{ token: "2.public-room" }),
|
||||
"a hidden public room must not start updating score boards"
|
||||
);
|
||||
close_room(room);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_no_room_claims_falls_back_to_public() {
|
||||
// Torn down before the end arrived, or a restart since the live started.
|
||||
let room = open_room("3336667", true, "3.gone-by-then");
|
||||
close_room(room);
|
||||
assert!(!is_private_party(&object!{ token: "3.gone-by-then" }));
|
||||
|
||||
// Never existed, and an end with no token at all.
|
||||
assert!(!is_private_party(&object!{ token: "4.never-existed" }));
|
||||
assert!(!is_private_party(&object!{}));
|
||||
}
|
||||
|
||||
// --- privacy is decided ONCE, at start ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_privacy_answer_is_recorded_at_start_and_outlives_the_room() {
|
||||
let room = open_room("551234", false, "5.recorded-private");
|
||||
let mut start_body = object!{ token: "5.recorded-private", master_live_id: STOCK_LIVE_ID };
|
||||
record_room_privacy(&mut start_body);
|
||||
assert_eq!(start_body["room_private"].as_bool(), Some(true));
|
||||
|
||||
// The party breaks up: the room is gone by the time the ends land.
|
||||
close_room(room);
|
||||
assert!(!is_private_party(&start_body), "the live lookup can no longer answer");
|
||||
|
||||
// The recorded answer still does, so the score board is still updated.
|
||||
assert!(recorded_privacy(Some(&start_body), &start_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_ender_of_one_party_reads_the_same_answer() {
|
||||
// Four members, one room, four /multi_live/start posts — and then the ends arrive
|
||||
// spread out, the last one after the room has been torn down. Asking the registry
|
||||
// at END time made the last poster's live PUBLIC while the first three's were
|
||||
// PRIVATE: one live, scored two different ways.
|
||||
let room = open_room("552345", false, "6.four-enders");
|
||||
let mut records: Vec<JsonValue> = (0..4)
|
||||
.map(|_| object!{ token: "6.four-enders", master_live_id: STOCK_LIVE_ID })
|
||||
.collect();
|
||||
for record in records.iter_mut() {
|
||||
record_room_privacy(record);
|
||||
}
|
||||
|
||||
// Three end while the room is alive, the fourth after it is gone.
|
||||
let end_body = object!{ token: "6.four-enders" };
|
||||
let mut answers: Vec<bool> = records[..3]
|
||||
.iter()
|
||||
.map(|r| recorded_privacy(Some(r), &end_body))
|
||||
.collect();
|
||||
close_room(room);
|
||||
answers.push(recorded_privacy(Some(&records[3]), &end_body));
|
||||
|
||||
assert_eq!(answers, vec![true; 4], "one live must score one way for everybody");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registry_miss_at_end_no_longer_flips_a_private_live_to_public() {
|
||||
let room = open_room("553456", false, "7.miss-at-end");
|
||||
let mut start_body = object!{ token: "7.miss-at-end" };
|
||||
record_room_privacy(&mut start_body);
|
||||
close_room(room);
|
||||
|
||||
// The end-time lookup misses and resolves to public...
|
||||
assert!(!is_private_party(&start_body));
|
||||
// ...but it is not what is asked any more.
|
||||
assert!(recorded_privacy(Some(&start_body), &start_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_room_records_public_and_stays_public() {
|
||||
let room = open_room("554567", true, "8.recorded-public");
|
||||
let mut start_body = object!{ token: "8.recorded-public" };
|
||||
record_room_privacy(&mut start_body);
|
||||
assert_eq!(start_body["room_private"].as_bool(), Some(false));
|
||||
assert!(!recorded_privacy(Some(&start_body), &start_body));
|
||||
close_room(room);
|
||||
assert!(!recorded_privacy(Some(&start_body), &start_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_with_no_recorded_answer_falls_back_to_the_live_lookup() {
|
||||
// Started before this was recorded, or started against a token the registry did
|
||||
// not know (a start POST that arrived after its own room died). Nothing is written
|
||||
// in that case, deliberately, so the fallback is what selects it.
|
||||
let room = open_room("555678", false, "9.no-record");
|
||||
let mut missed = object!{ token: "9.never-a-room" };
|
||||
record_room_privacy(&mut missed);
|
||||
assert!(missed["room_private"].is_null(), "a miss must record nothing");
|
||||
|
||||
let legacy = object!{ live_boost: 1 };
|
||||
let end_body = object!{ token: "9.no-record" };
|
||||
assert!(recorded_privacy(Some(&legacy), &end_body), "falls back to the live room");
|
||||
close_room(room);
|
||||
assert!(!recorded_privacy(Some(&legacy), &end_body), "and to public once it is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_count_does_not_double_count_the_poster() {
|
||||
let body = object!{
|
||||
|
||||
@@ -104,6 +104,7 @@ impl Value {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::Str(v) => Some(v),
|
||||
@@ -148,6 +149,7 @@ impl Map {
|
||||
self.get(key).and_then(Value::as_int)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_str(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
@@ -58,13 +58,6 @@ pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const PROP_ROOM_LEVEL: &str = "C0";
|
||||
const PROP_ROOM_POWER: &str = "C1";
|
||||
|
||||
// MultiPlayProtocol.RoomConst.Token — "{userId}.{guid}", minted by the master client in
|
||||
// MultiEventMatchingScene right before the live starts (CommitCurrentRoomToken +
|
||||
// PushCurrentRoomData) and mirrored to the whole party. Every member posts it as the
|
||||
// `token` field of /multi_live/start|end, so it is the only handle the HTTP side has on
|
||||
// which room a finishing live belongs to. The relay never writes it, only reads it back.
|
||||
const PROP_ROOM_TOKEN: &str = "C";
|
||||
|
||||
// Photon's well-known room properties are BYTE keys living in the same bag as the
|
||||
// game's string keys; the client transport encodes a byte key as "#" + decimal.
|
||||
// GamePropertyKey.IsOpen is 253 and IsVisible is 254, and the surviving Photon.Realtime
|
||||
@@ -133,18 +126,6 @@ struct Room {
|
||||
max_players: u8,
|
||||
visible: bool,
|
||||
open: bool,
|
||||
// Whether this room was created as a PRIVATE (join-by-code) party, latched once at
|
||||
// creation and never touched again. /multi_live/end branches the score-board write on
|
||||
// it, so it must NOT be re-derived from `visible` later: the game hides a room the
|
||||
// moment its members are decided (MultiMatchingView.SetRoomOpenVisible(false)), which
|
||||
// makes a public room mid-live indistinguishable from a private one by the live flags.
|
||||
//
|
||||
// The signal is the creation-time visibility, because that is exactly what separates
|
||||
// the client's two create paths: MultiPlayManager.CreatePublicRoom issues
|
||||
// CreateRoom("", 4, visible: true, open: true) and lets the server name the room, while
|
||||
// CreatePrivateRoom issues CreateRoom(code, 4, visible: false, open: true) — a private
|
||||
// party is by definition the one that is never listed for random matching.
|
||||
private: bool,
|
||||
props: Map,
|
||||
// Kept only so a future lobby-listing op can honour what the creator asked to
|
||||
// publish; the matcher reads C0/C1 straight off the room bag like Photon's SQL did.
|
||||
@@ -431,15 +412,12 @@ impl Registry {
|
||||
// disagree the bag wins, because the bag is what every client polls.
|
||||
let (mut visible, mut open) = (visible, open);
|
||||
apply_flag_props(&props, &mut visible, &mut open);
|
||||
// Latched here, from the settled creation-time visibility, and never updated.
|
||||
let private = !visible;
|
||||
// The creator is master and takes actor 1.
|
||||
let room = Room {
|
||||
lobby,
|
||||
max_players,
|
||||
visible,
|
||||
open,
|
||||
private,
|
||||
props,
|
||||
lobby_prop_keys,
|
||||
// The creator has nobody to notify, but its bag is seeded from the op-4
|
||||
@@ -916,26 +894,6 @@ impl Registry {
|
||||
|
||||
// --- introspection (tests, and a future webui panel) ----------------------
|
||||
|
||||
// Whether the room carrying this live token is a private (join-by-code) party, or None
|
||||
// when no live room claims the token.
|
||||
//
|
||||
// The token is matched against the ROOM bag rather than against the poster's account:
|
||||
// all up to four party members post the same token, and only the master ever wrote it,
|
||||
// so the bag is the one place the HTTP and relay halves meet. Room names are globally
|
||||
// unique but a token is not addressable by name, so this is a scan — the registry holds
|
||||
// at most a handful of live rooms and this runs once per /multi_live/end.
|
||||
pub fn token_room_is_private(&self, token: &str) -> Option<bool> {
|
||||
if token.is_empty() {
|
||||
// An unset room prop reads back as "" on the client, which would otherwise
|
||||
// match every room that never had a token pushed.
|
||||
return None;
|
||||
}
|
||||
self.rooms
|
||||
.values()
|
||||
.find(|room| room.props.get_str(PROP_ROOM_TOKEN) == Some(token))
|
||||
.map(|room| room.private)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn room_count(&self) -> usize {
|
||||
self.rooms.len()
|
||||
@@ -947,24 +905,8 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
// What /multi_live/start asks, while the room is certainly still alive: is this a private
|
||||
// party, or does no live room claim the token at all? The distinction matters to the
|
||||
// caller — an answer is recorded on the start record, a miss is not (see
|
||||
// multi_live::record_room_privacy).
|
||||
pub fn token_room_privacy(token: &str) -> Option<bool> {
|
||||
registry().token_room_is_private(token)
|
||||
}
|
||||
|
||||
// The same question with the miss folded away, for a start record from before the answer
|
||||
// was recorded at start time.
|
||||
//
|
||||
// A token no live room claims answers `false`. The room is torn down as soon as its last
|
||||
// active player leaves cleanly, and an end can arrive after that (a client that quit the
|
||||
// room before its POST landed, or a server restart), so "unknown" has to resolve to
|
||||
// something — and public is the official behaviour, which is the safe side to be wrong on.
|
||||
pub fn token_is_private_room(token: &str) -> bool {
|
||||
token_room_privacy(token).unwrap_or(false)
|
||||
}
|
||||
// This file is like 1.5k lines of tests :skull:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -2300,88 +2242,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- live-token lookup (the /multi_live/end score-board branch) -----------------
|
||||
//
|
||||
// The end handler has nothing but the room token to go on, and has to tell a private
|
||||
// party from public matchmaking with it. The trap is that the live flags cannot answer
|
||||
// the question: the game hides a PUBLIC room as well, the moment its members are
|
||||
// decided, so privacy is latched at creation instead.
|
||||
|
||||
// MultiPlayManager.CreatePrivateRoom: the 6-digit code is the room name, and the room
|
||||
// is created hidden so random matching can never land in it.
|
||||
fn create_private(h: &mut Harness, id: ConnId, code: &str) {
|
||||
h.send(id, ClientMsg::CreateRoom {
|
||||
name: code.to_string(),
|
||||
max_players: 4,
|
||||
visible: false,
|
||||
open: true,
|
||||
props: Map::new(),
|
||||
lobby_prop_keys: vec![],
|
||||
player_props: Map::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// MultiEventMatchingScene: CommitCurrentRoomToken then PushCurrentRoomData, once, just
|
||||
// before the live starts.
|
||||
fn push_token(h: &mut Harness, id: ConnId, token: &str) {
|
||||
h.send(id, ClientMsg::SetRoomProps {
|
||||
props: Map::from_pairs(vec![(PROP_ROOM_TOKEN, Value::Str(token.to_string()))]),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_room_is_found_by_the_live_token_it_carries() {
|
||||
let mut h = Harness::new();
|
||||
let a = h.connect(1);
|
||||
let b = h.connect(2);
|
||||
create_private(&mut h, a, "042719");
|
||||
h.send(b, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() });
|
||||
push_token(&mut h, a, "1.abcd");
|
||||
|
||||
// Every party member posts this same token, so both ends resolve to the one room.
|
||||
assert_eq!(h.reg.token_room_is_private("1.abcd"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_room_stays_public_after_the_game_hides_it() {
|
||||
// The regression this whole field exists for: MultiMatchingView.SetRoomOpenVisible
|
||||
// (false) closes and hides a public room once its members are decided, so by the
|
||||
// time the live ends the current flags look exactly like a private room's.
|
||||
let mut h = Harness::new();
|
||||
let a = h.connect(1);
|
||||
h.create(a, "1234567", vec![]);
|
||||
push_token(&mut h, a, "1.efgh");
|
||||
assert_eq!(h.reg.token_room_is_private("1.efgh"), Some(false));
|
||||
|
||||
set_flags(&mut h, a, vec![
|
||||
(PROP_ROOM_IS_OPEN, Value::Bool(false)),
|
||||
(PROP_ROOM_IS_VISIBLE, Value::Bool(false)),
|
||||
]);
|
||||
assert_eq!(
|
||||
h.reg.token_room_is_private("1.efgh"),
|
||||
Some(false),
|
||||
"a hidden public room must not be mistaken for a private party"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_empty_token_matches_no_room() {
|
||||
let mut h = Harness::new();
|
||||
let a = h.connect(1);
|
||||
create_private(&mut h, a, "042719");
|
||||
push_token(&mut h, a, "1.abcd");
|
||||
|
||||
assert_eq!(h.reg.token_room_is_private("2.nope"), None);
|
||||
// A room whose master never pushed a token reads back "" on the client; that must
|
||||
// not match the first room in the registry.
|
||||
assert_eq!(h.reg.token_room_is_private(""), None);
|
||||
|
||||
// And once the last active player leaves, the room — and the answer — is gone.
|
||||
h.send(a, ClientMsg::LeaveRoom);
|
||||
assert_eq!(h.reg.room_count(), 0);
|
||||
assert_eq!(h.reg.token_room_is_private("1.abcd"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_drops_the_connection_record() {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -1,41 +1,262 @@
|
||||
use jzon::{object};
|
||||
use actix_web::{web, Responder};
|
||||
|
||||
use crate::router::{userdata, databases, Session, Api};
|
||||
use jzon::{array, object, JsonValue};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use crate::router::{chat, global, items, userdata, databases, Session, Api};
|
||||
use databases::csv::{table, Region};
|
||||
|
||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/story/read", web::post().to(read));
|
||||
}
|
||||
|
||||
async fn read(Session { key, body }: Session) -> impl Responder {
|
||||
let mut user = userdata::get_acc(&key);
|
||||
let part = body["master_story_part_id"].as_i64().unwrap();
|
||||
let master_id = databases::STORY[part.to_string()]["masterStoryId"].as_i64().unwrap();
|
||||
static READ_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
let index = user["story_list"].members().position(|r| r["master_story_id"] == master_id);
|
||||
fn has_read(user: &JsonValue, story: i64, part: i64) -> bool {
|
||||
user["story_list"].members().any(|s| s["master_story_id"] == story
|
||||
&& s["master_story_part_ids"].contains(part))
|
||||
}
|
||||
|
||||
if index.is_none() {
|
||||
user["story_list"].push(object!{
|
||||
master_story_id: master_id,
|
||||
master_story_part_ids: []
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
for story in user["story_list"].members_mut() {
|
||||
if story["master_story_id"] == master_id && !story["master_story_part_ids"].contains(part) {
|
||||
story["master_story_part_ids"].push(part).unwrap();
|
||||
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) {
|
||||
let mut counts = std::collections::HashMap::<i64, i64>::new();
|
||||
for live in user["live_list"].members() {
|
||||
let song = &databases::MUSIC[live["master_live_id"].to_string()];
|
||||
if !song.is_null() {
|
||||
*counts.entry(song["bandCategory"].as_i64().unwrap_or(0)).or_default()
|
||||
+= live["clear_count"].as_i64().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
for (_, master) in databases::MISSION_LIST.entries() {
|
||||
let condition = master["conditionType"].as_i64().unwrap_or(0);
|
||||
if !matches!(condition, 73 | 78) { continue; }
|
||||
let id = master["id"].as_i64().unwrap();
|
||||
let value = master["conditionValues"][0].as_i64().unwrap_or(0);
|
||||
let count = if condition == 73 { counts.get(&value).copied().unwrap_or(0) }
|
||||
else { super::event::get_points(value as u32, user) };
|
||||
if condition == 78 && count == 0 { continue; }
|
||||
set_progress(id, count, missions);
|
||||
}
|
||||
}
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
fn set_progress(id: i64, count: i64, missions: &mut JsonValue) {
|
||||
if !missions.members().any(|m| m["master_mission_id"] == id) {
|
||||
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
|
||||
}
|
||||
let master = &databases::MISSION_LIST[id.to_string()];
|
||||
let target = master["conditionNumber"].as_i64().unwrap();
|
||||
let mission = missions.members_mut().find(|m| m["master_mission_id"] == id).unwrap();
|
||||
if mission["status"].as_i64().unwrap_or(1) >= 2 { return; }
|
||||
let progress = count.max(mission["progress"].as_i64().unwrap_or(0)).min(target);
|
||||
mission["progress"] = progress.into();
|
||||
mission["status"] = if progress >= target { 2 } else { 1 }.into();
|
||||
}
|
||||
|
||||
pub fn visit_event(key: &str, event: u32) {
|
||||
let user = userdata::get_acc(key);
|
||||
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
|
||||
if !super::event::is_in_session(event, now) { return; }
|
||||
let mut missions = userdata::get_acc_missions(key);
|
||||
for (_, master) in databases::MISSION_LIST.entries() {
|
||||
if master["conditionType"] == 79 && master["conditionValues"][0] == event {
|
||||
set_progress(master["id"].as_i64().unwrap(), 1, &mut missions);
|
||||
}
|
||||
}
|
||||
refresh(&user, &mut missions);
|
||||
userdata::save_acc_missions(key, missions);
|
||||
}
|
||||
|
||||
Api(Some(object!{
|
||||
"gift_list":[],
|
||||
"updated_value_list":{
|
||||
"story_list": user["story_list"].clone()
|
||||
},
|
||||
"reward_list":[],
|
||||
"clear_mission_ids":[]
|
||||
}))
|
||||
async fn read(req: HttpRequest, Session { key, body }: Session) -> Api {
|
||||
let _read = crate::lock_onto_mutex!(READ_LOCK);
|
||||
let Some(part_id) = body["master_story_part_id"].as_i64() else { return Api(None); };
|
||||
let part = &databases::STORY[part_id.to_string()];
|
||||
let Some(story_id) = part["masterStoryId"].as_i64() else { return Api(None); };
|
||||
let mut user = userdata::get_acc(&key);
|
||||
let empty = || object! {gift_list: [], updated_value_list: [], reward_list: [], clear_mission_ids: []};
|
||||
if has_read(&user, story_id, part_id) { return Api(Some(empty())); }
|
||||
if let Some(previous) = databases::STORY.entries().map(|(_, p)| p)
|
||||
.filter(|p| p["masterStoryId"] == story_id && p["number"].as_i64() < part["number"].as_i64())
|
||||
.max_by_key(|p| p["number"].as_i64()) {
|
||||
if !has_read(&user, story_id, previous["id"].as_i64().unwrap()) { return Api(None); }
|
||||
}
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
refresh(&user, &mut missions);
|
||||
let releases = table(Region::Jp, "story_release");
|
||||
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
|
||||
let stories = table(Region::Jp, "story");
|
||||
let Some(story_master) = stories.members().find(|s| s["id"] == story_id) else { return Api(None); };
|
||||
let mut fully_open = false;
|
||||
if let Some(release) = releases.members().find(|r| r["masterStoryPartId"] == part_id) {
|
||||
fully_open = global::parse_datetime(release["openedAtAfterEvent"].as_str().unwrap_or(""))
|
||||
.is_some_and(|at| now >= at);
|
||||
if story_master["type"] == 3 {
|
||||
let event_stories = table(Region::Jp, "event_story");
|
||||
let ended = event_stories.members().find(|e| e["masterStoryId"] == story_id)
|
||||
.and_then(|e| {
|
||||
let event = &databases::EVENTS[e["masterEventId"].to_string()];
|
||||
global::parse_datetime(databases::RELEASE_LABEL[event["masterReleaseLabelId"].to_string()]["closedAt"].as_str().unwrap_or(""))
|
||||
}).is_some_and(|end| now > end);
|
||||
fully_open &= ended;
|
||||
}
|
||||
let mission = release["masterMissionId"].as_i64().unwrap_or(0);
|
||||
if !fully_open {
|
||||
let rank = items::get_user_rank_data(user["user"]["exp"].as_i64().unwrap_or(0))["rank"].as_i64().unwrap_or(0);
|
||||
if rank < release["userRank"].as_i64().unwrap_or(0) { return Api(None); }
|
||||
let live = release["masterLiveId"].as_i64().unwrap_or(0);
|
||||
let score = release["liveScore"].as_i64().unwrap_or(0);
|
||||
if live != 0 && score != 0 && !user["live_list"].members().any(|l|
|
||||
l["master_live_id"] == live && l["clear_count"].as_i64().unwrap_or(0) > 0
|
||||
&& l["high_score"].as_i64().unwrap_or(0) >= score) { return Api(None); }
|
||||
}
|
||||
if !fully_open && mission != 0 && !missions.members().any(|m|
|
||||
m["master_mission_id"] == mission && m["status"].as_i64().unwrap_or(0) >= 2) {
|
||||
return Api(None);
|
||||
}
|
||||
}
|
||||
if !fully_open && (!super::event::release_label_is_open(part["masterReleaseLabelId"].as_i64().unwrap_or(0), now)
|
||||
|| !super::event::release_label_is_open(story_master["masterReleaseLabelId"].as_i64().unwrap_or(0), now)) {
|
||||
return Api(None);
|
||||
}
|
||||
let mut home = userdata::get_acc_home(&key);
|
||||
let mut chats = userdata::get_acc_chats(&key);
|
||||
let mut response = empty();
|
||||
let mut rooms = array![];
|
||||
let mut chapters = array![];
|
||||
let region = if items::get_region(req.headers()) { Region::Jp } else { Region::En };
|
||||
let mut rewards: Vec<_> = table(region, "story_reward").members()
|
||||
.filter(|r| r["id"] == part["masterStoryRewardId"]).cloned().collect();
|
||||
// Official replies group room rewards before chapter rewards.
|
||||
rewards.sort_by_key(|r| (r["type"].as_i64(), r["number"].as_i64()));
|
||||
for reward in rewards {
|
||||
let value = reward["value"].as_i64().unwrap();
|
||||
match reward["type"].as_i64().unwrap() {
|
||||
1 => {
|
||||
if home["home"]["gift_list"].len() >= items::GIFT_LIMIT { return Api(None); }
|
||||
let mut gift = reward.clone();
|
||||
let next_id = loop {
|
||||
let id = rand::random::<u64>() & ((1u64 << 53) - 1);
|
||||
if id != 0 && !home["home"]["gift_list"].members().any(|g| g["id"] == id) { break id; }
|
||||
};
|
||||
gift["id"] = next_id.into();
|
||||
let reason = if region == Region::Jp { "スクールアイドルの日常の報酬です。" } else { "Daily Life of School idols reward." };
|
||||
let gift = items::gift_item(&gift, reason, &mut home);
|
||||
response["gift_list"].push(gift).unwrap();
|
||||
}
|
||||
16 => {} // Chapter rewards below materialize room ownership.
|
||||
17 => {
|
||||
if !chat::add_chat_from_chapter_id(value, &mut chats) { continue; }
|
||||
chapters.push(value).unwrap();
|
||||
let chapter = &databases::CHAPTERS_MASTER[value.to_string()];
|
||||
let room = databases::CHATS[chapter["masterChatId"].to_string()][chapter["roomId"].to_string()]["id"].clone();
|
||||
rooms.push(room).unwrap();
|
||||
}
|
||||
_ => return Api(None),
|
||||
}
|
||||
response["reward_list"].push(object! {
|
||||
type: reward["type"].clone(), value: value,
|
||||
level: reward["level"].clone(), amount: reward["amount"].clone()
|
||||
}).unwrap();
|
||||
}
|
||||
if !chapters.is_empty() {
|
||||
response["updated_value_list"] = object! {master_chat_room_ids: rooms, master_chat_chapter_ids: chapters};
|
||||
}
|
||||
if !user["story_list"].members().any(|s| s["master_story_id"] == story_id) {
|
||||
user["story_list"].push(object! {master_story_id: story_id, master_story_part_ids: []}).unwrap();
|
||||
}
|
||||
for story in user["story_list"].members_mut().filter(|s| s["master_story_id"] == story_id) {
|
||||
story["master_story_part_ids"].push(part_id).unwrap();
|
||||
}
|
||||
userdata::save_acc_home(&key, home);
|
||||
userdata::save_acc_chats(&key, chats);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
userdata::save_acc(&key, user);
|
||||
Api(Some(response))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_web::test]
|
||||
async fn event_entry_and_point_thresholds_unlock_story_in_its_original_period() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (_, key) = userdata::starter::create("Event story test").unwrap();
|
||||
userdata::save_server_data(&key, object! {
|
||||
server_time: global::parse_datetime("2023/04/22 12:00:00").unwrap(), server_time_set: global::timestamp()
|
||||
});
|
||||
let request = || actix_web::test::TestRequest::default().to_http_request();
|
||||
let part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
|
||||
assert!(read(request(), part(40001001)).await.0.is_none());
|
||||
visit_event(&key, 101);
|
||||
assert!(read(request(), part(40001001)).await.0.is_some());
|
||||
let mut user = userdata::get_acc(&key);
|
||||
super::super::event::give_event_points(101, 4999, &mut user);
|
||||
userdata::save_acc(&key, user);
|
||||
assert!(read(request(), part(40001002)).await.0.is_none());
|
||||
let mut user = userdata::get_acc(&key);
|
||||
super::super::event::give_event_points(101, 1, &mut user);
|
||||
userdata::save_acc(&key, user);
|
||||
assert!(read(request(), part(40001002)).await.0.is_some());
|
||||
assert!(read(request(), part(40001003)).await.0.is_none());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn story_unlocks_before_and_after_full_release() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (_, key) = userdata::starter::create("Story unlock test").unwrap();
|
||||
let request = || actix_web::test::TestRequest::default().to_http_request();
|
||||
let read_part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
|
||||
userdata::save_server_data(&key, object! {
|
||||
server_time: global::parse_datetime("2023/12/01 12:00:00").unwrap(), server_time_set: global::timestamp()
|
||||
});
|
||||
assert!(read(request(), read_part(210001001)).await.0.is_none());
|
||||
let mut user = userdata::get_acc(&key);
|
||||
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
|
||||
userdata::save_acc(&key, user);
|
||||
assert!(read(request(), read_part(210001001)).await.0.is_some());
|
||||
assert!(read(request(), read_part(210001002)).await.0.is_none());
|
||||
let mut user = userdata::get_acc(&key);
|
||||
user["live_list"][0]["clear_count"] = 10.into();
|
||||
userdata::save_acc(&key, user);
|
||||
assert!(read(request(), read_part(210001002)).await.0.is_some());
|
||||
// Event parts remain gated before full release, including previous-part order.
|
||||
assert!(read(request(), read_part(40001001)).await.0.is_none());
|
||||
userdata::save_server_data(&key, object! {
|
||||
server_time: global::parse_datetime("2024/03/01 12:00:00").unwrap(), server_time_set: global::timestamp()
|
||||
});
|
||||
assert!(read(request(), read_part(40001002)).await.0.is_none());
|
||||
assert!(read(request(), read_part(40001001)).await.0.is_some());
|
||||
assert!(read(request(), read_part(40001002)).await.0.is_some());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn story_rewards_persist_and_replays_do_not_pay_again() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (_, key) = userdata::starter::create("Story test").unwrap();
|
||||
userdata::save_acc_chats(&key, array![]);
|
||||
let request = || actix_web::test::TestRequest::default().to_http_request();
|
||||
let reply = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
|
||||
// jp/logs.txt:1871-1902, the first Aqours introduction read.
|
||||
assert_eq!(reply["updated_value_list"], object! {
|
||||
master_chat_room_ids: [2001001, 2101001], master_chat_chapter_ids: [200100101, 210100101]
|
||||
});
|
||||
assert_eq!(reply["reward_list"].len(), 4);
|
||||
assert_eq!(reply["reward_list"][0]["type"], 16);
|
||||
assert_eq!(userdata::get_acc_chats(&key).len(), 2);
|
||||
assert!(has_read(&userdata::get_acc(&key), 129999, 20000001));
|
||||
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
|
||||
assert!(repeat["reward_list"].is_empty());
|
||||
|
||||
// jp/logs.txt:18507-18538: daily-life gems go to gifts, not straight to the wallet.
|
||||
let mut user = userdata::get_acc(&key);
|
||||
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
|
||||
userdata::save_acc(&key, user);
|
||||
let gems = userdata::get_acc(&key)["gem"].clone();
|
||||
let before = userdata::get_acc_home(&key)["home"]["gift_list"].len();
|
||||
let first = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
|
||||
assert_eq!(first["gift_list"].len(), 1);
|
||||
assert_eq!(first["gift_list"][0]["amount"], 100);
|
||||
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
|
||||
assert_eq!(userdata::get_acc(&key)["gem"], gems);
|
||||
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
|
||||
assert!(repeat["gift_list"].is_empty());
|
||||
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
|
||||
assert!(read(request(), Session {key, body: object! {master_story_part_id: -1}}).await.0.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,6 @@ pub enum UserView {
|
||||
const DEFAULT_CARD: i64 = 10010001;
|
||||
|
||||
lazy_static! {
|
||||
// Each character's lowest official card id: the stand-in shown to viewers
|
||||
// who can't resolve a custom card of that character
|
||||
static ref OFFICIAL_CARD_BY_CHARACTER: HashMap<i64, i64> = {
|
||||
let mut rv: HashMap<i64, i64> = HashMap::new();
|
||||
for entry in databases::CARD_LIST.entries() {
|
||||
@@ -90,8 +88,6 @@ lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
// The character behind any card id: baked masterdata first, then the runtime
|
||||
// custom-card db, then the imported band's id arithmetic (prefix - 9000)
|
||||
fn card_character(id: i64) -> Option<i64> {
|
||||
let card = &databases::CARD_LIST[id.to_string()];
|
||||
if !card.is_empty() {
|
||||
@@ -103,9 +99,6 @@ fn card_character(id: i64) -> Option<i64> {
|
||||
Some(id / 10000 - 9000)
|
||||
}
|
||||
|
||||
// A custom card the viewer can't resolve shows as its character's base
|
||||
// official card - the right face, never a crash. Characters with no official
|
||||
// card (custom ones included) fall back to the default
|
||||
fn proxy_card_id(id: i64) -> i64 {
|
||||
if !card::is_custom(id) {
|
||||
return id;
|
||||
@@ -119,6 +112,28 @@ fn proxy_card_id(id: i64) -> i64 {
|
||||
*rv
|
||||
}
|
||||
|
||||
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: []
|
||||
}
|
||||
}
|
||||
|
||||
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 +191,22 @@ 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)
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -223,6 +248,9 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) ->
|
||||
rv
|
||||
}
|
||||
|
||||
|
||||
// here are some tests that ai wrote...
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -237,6 +265,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]
|
||||
@@ -254,11 +309,11 @@ mod tests {
|
||||
|
||||
// A runtime card on an official character proxies to that character
|
||||
let id = db::next_card_id();
|
||||
db::insert_card(id, 2003, 5101, &jzon::object!{ "master_card_id": id, "rarity": 1 }, true, false);
|
||||
db::insert_card(id, 2003, 5101, &jzon::object!{ "master_card_id": id, "rarity": 1 }, true, false).unwrap();
|
||||
assert_eq!(proxy_card_id(id), 20030001);
|
||||
// On a custom character (no official card) it falls to the default
|
||||
let orphan = db::next_card_id();
|
||||
db::insert_card(orphan, db::FIRST_CHARACTER_ID, 5101, &jzon::object!{ "master_card_id": orphan, "rarity": 1 }, true, false);
|
||||
db::insert_card(orphan, db::FIRST_CHARACTER_ID, 5101, &jzon::object!{ "master_card_id": orphan, "rarity": 1 }, true, false).unwrap();
|
||||
assert_eq!(proxy_card_id(orphan), DEFAULT_CARD);
|
||||
// A deleted/unknown runtime id can't resolve a character either
|
||||
let unknown = db::next_card_id() + 5000;
|
||||
@@ -281,9 +336,9 @@ mod tests {
|
||||
wipe(5102);
|
||||
|
||||
let published = db::next_card_id();
|
||||
db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false);
|
||||
db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false).unwrap();
|
||||
let draft = db::next_card_id();
|
||||
db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false);
|
||||
db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false).unwrap();
|
||||
|
||||
assert!(custom_card::viewer_can_resolve(published, custom_card::PROTOCOL_VERSION));
|
||||
assert!(!custom_card::viewer_can_resolve(draft, custom_card::PROTOCOL_VERSION));
|
||||
|
||||
@@ -17,6 +17,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
.route("/migration", web::post().to(migration))
|
||||
.route("/gglrequestmigrationcode", web::post().to(request_migration_code))
|
||||
.route("/gglverifymigrationcode", web::post().to(verify_migration_code))
|
||||
.route("/migration/card/list", web::post().to(migration_card_list))
|
||||
.route("/migration/card/link", web::post().to(migration_card_link))
|
||||
.route("/migration/card/unlink", web::post().to(migration_card_unlink))
|
||||
.route("/getregisteredplatformlist", web::post().to(getregisteredplatformlist))
|
||||
.route("/sif/migrate", web::post().to(sif_migrate))
|
||||
.route("/ss/migrate", web::post().to(sifas_migrate))
|
||||
@@ -45,6 +48,9 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
|
||||
}
|
||||
}
|
||||
userdata::save_acc(&key, user.clone());
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
let cleared = super::beginner_mission::refresh(&user, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
|
||||
Api(Some(object!{
|
||||
"deck": {
|
||||
@@ -52,7 +58,7 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
|
||||
"leader_role": 0,
|
||||
"main_card_ids": body["main_card_ids"].clone()
|
||||
},
|
||||
"clear_mission_ids": []
|
||||
"clear_mission_ids": cleared
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -67,8 +73,7 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime custom cards are unresolvable below protocol 3. start.rs blocks
|
||||
// flagged accounts on old clients, so this is belt-and-braces
|
||||
// Don't allow account downgrade (will crash client)
|
||||
if !crate::router::custom_card::client_supports(&req) {
|
||||
crate::router::custom_card::strip_unsupported(&mut user);
|
||||
}
|
||||
@@ -168,7 +173,11 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
|
||||
user["user"]["master_title_ids"][0] = body["master_title_ids"][0].clone();
|
||||
}
|
||||
if !body["birthday"].is_null() {
|
||||
user["user"]["birthday"][0] = body["birthday"].clone();
|
||||
let Some(birthday) = body["birthday"].as_str() else { return Api(None); };
|
||||
if !valid_birthday(birthday) { return Api(None); }
|
||||
let previous = user["user"]["birthday"].as_str().unwrap_or("");
|
||||
if valid_birthday(previous) && previous != birthday { return Api(None); }
|
||||
user["user"]["birthday"] = birthday.into();
|
||||
}
|
||||
|
||||
userdata::save_acc(&key, user.clone());
|
||||
@@ -179,11 +188,46 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn valid_birthday(value: &str) -> bool {
|
||||
if value.len() != 4 || !value.bytes().all(|b| b.is_ascii_digit()) { return false; }
|
||||
let month: usize = value[..2].parse().unwrap();
|
||||
let day: u8 = value[2..].parse().unwrap();
|
||||
let days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
(1..=12).contains(&month) && day > 0 && day <= days[month - 1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod birthday_tests {
|
||||
use super::*;
|
||||
|
||||
#[actix_web::test]
|
||||
async fn birthday_update_round_trips_as_mmdd() {
|
||||
let _test = crate::runtime::lock_test_data_path();
|
||||
let (_, key) = userdata::starter::create("Birthday test").unwrap();
|
||||
// jp/logs.txt:157322-157353 stores this as a string, not an array.
|
||||
user_post(Session {key: key.clone(), body: object! {birthday: "0331"}}).await;
|
||||
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
|
||||
user_post(Session {key: key.clone(), body: object! {birthday: "0230"}}).await;
|
||||
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
|
||||
user_post(Session {key: key.clone(), body: object! {birthday: "0401"}}).await;
|
||||
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
|
||||
let mut legacy = userdata::get_acc(&key);
|
||||
legacy["user"]["birthday"] = array!["0331"];
|
||||
userdata::save_acc(&key, legacy);
|
||||
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
|
||||
assert!(valid_birthday("0229"));
|
||||
for invalid in ["", "0001", "1301", "0100", "0431", "abcd", "2024"] {
|
||||
assert!(!valid_birthday(invalid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn announcement(Login(key): Login) -> impl Responder {
|
||||
|
||||
let mut user = userdata::get_acc_home(&key);
|
||||
|
||||
user["home"]["new_announcement_flag"] = (0).into();
|
||||
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
|
||||
|
||||
userdata::save_acc_home(&key, user);
|
||||
|
||||
@@ -192,9 +236,11 @@ pub async fn announcement(Login(key): Login) -> impl Responder {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_migration_code(Body(body): Body) -> impl Responder {
|
||||
|
||||
let Some(user_id) = body["user_id"].as_i64() else { return Api(None); };
|
||||
async fn get_migration_code(Session { key, .. }: Session) -> impl Responder {
|
||||
let user_id = userdata::uid_from_login_token(&key);
|
||||
if user_id == 0 {
|
||||
return Api(None);
|
||||
}
|
||||
let code = userdata::user::migration::get_acc_token(user_id);
|
||||
|
||||
Api(Some(object!{
|
||||
@@ -207,6 +253,11 @@ async fn register_password(Session { key, body }: Session) -> impl Responder {
|
||||
let user = userdata::get_acc(&key);
|
||||
|
||||
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &body["pass"].to_string());
|
||||
if !body["pass"].as_str().unwrap_or("").is_empty() {
|
||||
let mut missions = userdata::get_acc_missions(&key);
|
||||
super::beginner_mission::advance(25, None, 1, &mut missions);
|
||||
userdata::save_acc_missions(&key, missions);
|
||||
}
|
||||
|
||||
Api(Some(array![]))
|
||||
}
|
||||
@@ -240,6 +291,46 @@ async fn request_migration_code(Body(body): Body) -> impl Responder {
|
||||
"twxuid": user["login_token"].to_string()
|
||||
}))
|
||||
}
|
||||
async fn migration_card_list(Session { key, .. }: Session) -> impl Responder {
|
||||
let user_id = userdata::uid_from_login_token(&key);
|
||||
if user_id == 0 {
|
||||
return Api(None);
|
||||
}
|
||||
Api(Some(object!{
|
||||
"card_ids": userdata::user::migration::cards_of_account(user_id)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn migration_card_link(Session { key, body }: Session) -> impl Responder {
|
||||
let user_id = userdata::uid_from_login_token(&key);
|
||||
if user_id == 0 {
|
||||
return Api(None);
|
||||
}
|
||||
match userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), user_id) {
|
||||
Ok((card_id, rebound)) => Api(Some(object!{
|
||||
"card_id": card_id,
|
||||
"rebound": rebound
|
||||
})),
|
||||
Err(_) => Api(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn migration_card_unlink(Session { key, body }: Session) -> impl Responder {
|
||||
let user_id = userdata::uid_from_login_token(&key);
|
||||
if user_id == 0 {
|
||||
return Api(None);
|
||||
}
|
||||
let Some(card_id) = userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
|
||||
return Api(None);
|
||||
};
|
||||
if !userdata::user::migration::remove_card_of(&card_id, user_id) {
|
||||
return Api(None);
|
||||
}
|
||||
Api(Some(object!{
|
||||
"card_id": card_id
|
||||
}))
|
||||
}
|
||||
|
||||
async fn migration(Body(body): Body) -> impl Responder {
|
||||
|
||||
let user = userdata::get_name_and_rank(body["user_id"].to_string().parse::<i64>().unwrap());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod user;
|
||||
pub mod starter;
|
||||
|
||||
use rusqlite::params;
|
||||
use lazy_static::lazy_static;
|
||||
@@ -96,54 +97,6 @@ INSERT OR IGNORE INTO exchange (user_id, exchange) SELECT user_id, '[]' FROM use
|
||||
}
|
||||
}
|
||||
|
||||
// maybe we will use this later
|
||||
/*
|
||||
pub fn downgrade_account_cards(user: &JsonValue) -> JsonValue {
|
||||
let mut rv = user.clone();
|
||||
|
||||
let mut cards = array![];
|
||||
let mut ids = array![];
|
||||
for data in user["card_list"].members() {
|
||||
let id = data["master_card_id"].as_i64().unwrap_or(0);
|
||||
let downgraded = guest::proxy_card_id(id);
|
||||
// Whole characters share one downgrade, and the client can't hold the
|
||||
// same card twice
|
||||
if ids.contains(downgraded) {
|
||||
continue;
|
||||
}
|
||||
ids.push(downgraded).unwrap();
|
||||
let mut data = data.clone();
|
||||
if downgraded != id {
|
||||
data["id"] = downgraded.into();
|
||||
data["master_card_id"] = downgraded.into();
|
||||
}
|
||||
cards.push(data).unwrap();
|
||||
}
|
||||
rv["card_list"] = cards;
|
||||
|
||||
for deck in rv["deck_list"].members_mut() {
|
||||
let mut used = array![];
|
||||
for slot in deck["main_card_ids"].members_mut() {
|
||||
let id = guest::proxy_card_id(slot.as_i64().unwrap_or(0));
|
||||
// Cards the downgrade merged away leave the slot empty
|
||||
if id == 0 || used.contains(id) {
|
||||
*slot = (0).into();
|
||||
continue;
|
||||
}
|
||||
used.push(id).unwrap();
|
||||
*slot = id.into();
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
||||
let id = rv["user"][key].as_i64().unwrap_or(0);
|
||||
rv["user"][key] = guest::proxy_card_id(id).into();
|
||||
}
|
||||
|
||||
rv
|
||||
}
|
||||
*/
|
||||
|
||||
fn acc_exists(uid: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT user_id FROM userdata WHERE user_id=?1", params!(uid)).is_ok()
|
||||
}
|
||||
@@ -246,9 +199,6 @@ fn get_uid(token: &str) -> i64 {
|
||||
data.parse::<i64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
// The account a login token belongs to, 0 when the token is unknown. The HTTP layer
|
||||
// never needs this (it keys everything off the token itself), but the multi-live relay
|
||||
// authenticates a {userId, token} pair and has to check the two agree.
|
||||
pub fn uid_from_login_token(token: &str) -> i64 {
|
||||
get_uid(token)
|
||||
}
|
||||
@@ -300,12 +250,7 @@ fn get_data(auth_key: &str, row: &str) -> JsonValue {
|
||||
jzon::parse(&result.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
// Deleted custom songs leave stale score/clear records behind. They're wiped
|
||||
// lazily when the userdata is pulled: collect the user's own music-id-keyed
|
||||
// rows in the custom range (official ids are never candidates) and drop the
|
||||
// ones whose id no longer exists in the catalog. Custom ids are never reused,
|
||||
// so the wipe is final. A song that still exists but isn't visible to this
|
||||
// user is NOT wiped - existence is what's checked, not visibility
|
||||
// Prune deleted custom songs
|
||||
fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
|
||||
// Feature off: never touch custom_songs.db, leave userdata untouched
|
||||
if crate::router::custom_song::disabled() {
|
||||
@@ -323,7 +268,9 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
|
||||
if candidates.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let dead = custom_song::dead_music_ids(&candidates);
|
||||
let Some(dead) = custom_song::dead_music_ids(&candidates) else {
|
||||
return false;
|
||||
};
|
||||
if dead.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -340,13 +287,7 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// Deleted custom cards leave stale card_list rows behind - and a card_list id
|
||||
// the client can't resolve aborts its whole login. Wiped lazily when the
|
||||
// userdata is pulled, mirroring remove_deleted_custom_songs: only the runtime
|
||||
// band is a candidate (official/imported ids never are), ids are never
|
||||
// reused, so the wipe is final. A card that still exists but is unpublished
|
||||
// is NOT wiped - existence is what's checked, and the catalog keeps serving
|
||||
// owned ids (custom_card::owned_runtime_ids) so holders still resolve them
|
||||
// Prune deleted custom cards
|
||||
fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
|
||||
// Feature off: never touch custom_cards.db, leave userdata untouched
|
||||
if crate::router::custom_card::disabled() {
|
||||
@@ -362,7 +303,9 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
|
||||
if candidates.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let dead = custom_card::dead_card_ids(&candidates);
|
||||
let Some(dead) = custom_card::dead_card_ids(&candidates) else {
|
||||
return false;
|
||||
};
|
||||
if dead.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -381,9 +324,6 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dead favorite/guest card repoints to the account's first remaining
|
||||
// card (every account has its tutorial cards; the fallback can't trigger
|
||||
// in practice)
|
||||
let fallback = user["card_list"][0]["master_card_id"].as_i64().unwrap_or(10010001);
|
||||
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
||||
if dead.contains(user["user"][key].as_i64().unwrap_or(0)) {
|
||||
@@ -397,6 +337,12 @@ pub fn get_acc(auth_key: &str) -> JsonValue {
|
||||
let mut user = get_data(auth_key, "userdata");
|
||||
cleanup_account(&mut user);
|
||||
let mut changed = remove_deleted_custom_songs(&mut user);
|
||||
// Repair the old /user writer's ["MMDD"] shape without losing the chosen birthday.
|
||||
if user["user"]["birthday"].is_array() {
|
||||
let birthday = user["user"]["birthday"][0].as_str().unwrap_or("").to_owned();
|
||||
user["user"]["birthday"] = if super::user::valid_birthday(&birthday) { birthday } else { String::new() }.into();
|
||||
changed = true;
|
||||
}
|
||||
if remove_deleted_custom_cards(&mut user) {
|
||||
changed = true;
|
||||
}
|
||||
@@ -417,7 +363,9 @@ pub fn get_acc_home(auth_key: &str) -> JsonValue {
|
||||
user
|
||||
}
|
||||
pub fn get_acc_missions(auth_key: &str) -> JsonValue {
|
||||
get_data(auth_key, "missions")
|
||||
let mut missions = get_data(auth_key, "missions");
|
||||
super::beginner_mission::ensure(&mut missions);
|
||||
missions
|
||||
}
|
||||
pub fn get_acc_loginbonus(auth_key: &str) -> JsonValue {
|
||||
get_data(auth_key, "loginbonus")
|
||||
@@ -483,22 +431,6 @@ pub fn save_server_data(auth_key: &str, data: JsonValue) {
|
||||
save_data(auth_key, "server_data", data);
|
||||
}
|
||||
|
||||
// Read-modify-write of one account's server_data as ONE atomic step.
|
||||
//
|
||||
// get_server_data + save_server_data open a connection each, so two requests for the same
|
||||
// account both read the pre-state and the later write wins - which for a record that is
|
||||
// meant to be spent exactly once (the started-live record /multi_live/end awards off) means
|
||||
// both ends see it and both award. Everything here happens inside a single BEGIN IMMEDIATE
|
||||
// transaction instead, so concurrent callers serialise and the second one observes what the
|
||||
// first one wrote.
|
||||
//
|
||||
// `f` sees the parsed server_data and returns whatever the caller needs out of it; the
|
||||
// (possibly mutated) value is written back before the transaction commits. get_key runs
|
||||
// BEFORE the transaction because it can create the account, which writes on its own
|
||||
// connection and would otherwise deadlock against our write lock.
|
||||
//
|
||||
// A database error yields T::default() rather than a panic: the callers all have a
|
||||
// "nothing to spend" branch, which is the right answer when the record could not be read.
|
||||
pub fn modify_server_data<T: Default>(auth_key: &str, f: impl FnOnce(&mut JsonValue) -> T) -> T {
|
||||
let key = get_key(auth_key);
|
||||
let rv = DATABASE.lock_and_transact(|conn| {
|
||||
@@ -787,6 +719,29 @@ pub fn export_user(token: &str) -> Option<JsonValue> {
|
||||
})
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
crate::router::custom_song::purge_owner(user_id);
|
||||
crate::router::custom_card::purge_owner(user_id);
|
||||
crate::router::custom_3dmv::purge_owner(user_id);
|
||||
}
|
||||
|
||||
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,27 +757,16 @@ 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();
|
||||
dead_uids.len()
|
||||
}
|
||||
|
||||
|
||||
// more tests???
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -837,10 +781,10 @@ mod tests {
|
||||
let mut user = get_acc(token);
|
||||
|
||||
let deleted_id = custom_song::next_music_id();
|
||||
custom_song::insert_song(deleted_id, 1, &object!{music_id: deleted_id}, "public", &array![], false);
|
||||
custom_song::insert_song(deleted_id, 1, &object!{music_id: deleted_id}, "public", &array![], false).unwrap();
|
||||
// Exists but isn't visible to this user - must survive the wipe
|
||||
let private_id = custom_song::next_music_id();
|
||||
custom_song::insert_song(private_id, 1, &object!{music_id: private_id}, "private", &array![], false);
|
||||
custom_song::insert_song(private_id, 1, &object!{music_id: private_id}, "private", &array![], false).unwrap();
|
||||
|
||||
// 1100101 is a real stock live-id shape (7-digit, >= FIRST_MUSIC_ID): it
|
||||
// must survive the custom-song wipe, unlike an unrealistic sub-10000 id
|
||||
@@ -865,7 +809,7 @@ mod tests {
|
||||
assert_eq!(user["live_list"].len(), 3);
|
||||
assert_eq!(user["live_mission_list"].len(), 3);
|
||||
|
||||
custom_song::delete_song(deleted_id);
|
||||
custom_song::delete_song(deleted_id).unwrap();
|
||||
|
||||
// The next pull drops the dead id's records and only those
|
||||
let user = get_acc(token);
|
||||
@@ -894,10 +838,10 @@ mod tests {
|
||||
let mut user = get_acc(token);
|
||||
|
||||
let deleted_id = custom_card::next_card_id();
|
||||
custom_card::insert_card(deleted_id, 1001, 1, &jzon::object!{ "master_card_id": deleted_id, "rarity": 1 }, true, true);
|
||||
custom_card::insert_card(deleted_id, 1001, 1, &jzon::object!{ "master_card_id": deleted_id, "rarity": 1 }, true, true).unwrap();
|
||||
// Exists but was unpublished - must survive the wipe
|
||||
let unpublished_id = custom_card::next_card_id();
|
||||
custom_card::insert_card(unpublished_id, 1001, 1, &jzon::object!{ "master_card_id": unpublished_id, "rarity": 1 }, false, false);
|
||||
custom_card::insert_card(unpublished_id, 1001, 1, &jzon::object!{ "master_card_id": unpublished_id, "rarity": 1 }, false, false).unwrap();
|
||||
|
||||
for id in [deleted_id, unpublished_id] {
|
||||
user["card_list"].push(jzon::object!{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,30 +12,116 @@ use base64::{Engine as _, engine::general_purpose};
|
||||
fn generate_token() -> String {
|
||||
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let mut rng = rand::rng();
|
||||
let random_string: String = (0..16)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..charset.len());
|
||||
charset.chars().nth(idx).unwrap()
|
||||
})
|
||||
.collect();
|
||||
random_string
|
||||
loop {
|
||||
let random_string: String = (0..16)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..charset.len());
|
||||
charset.chars().nth(idx).unwrap()
|
||||
})
|
||||
.collect();
|
||||
if !random_string.chars().all(|c| c.is_ascii_digit()) {
|
||||
return random_string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A linked NESiCA card id works as the transfer code; the password is still required
|
||||
pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
|
||||
let database = userdata::get_userdata_database();
|
||||
let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token));
|
||||
if data.is_err() {
|
||||
return object!{success: false};
|
||||
}
|
||||
if verify_password(password, &data.unwrap()) {
|
||||
let uid: i64 = database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap();
|
||||
let login_token = userdata::get_login_token(uid);
|
||||
if login_token == String::new() {
|
||||
let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) {
|
||||
if !verify_password(password, &hash) {
|
||||
return object!{success: false};
|
||||
}
|
||||
return object!{success: true, login_token: login_token, user_id: uid};
|
||||
database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap()
|
||||
} else {
|
||||
let Some(uid) = valid_card_id(token).and_then(|card| card_user(&card)) else {
|
||||
return object!{success: false};
|
||||
};
|
||||
let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)) else {
|
||||
return object!{success: false};
|
||||
};
|
||||
if !verify_password(password, &hash) {
|
||||
return object!{success: false};
|
||||
}
|
||||
uid
|
||||
};
|
||||
let login_token = userdata::get_login_token(uid);
|
||||
if login_token == String::new() {
|
||||
return object!{success: false};
|
||||
}
|
||||
object!{success: false}
|
||||
object!{success: true, login_token: login_token, user_id: uid}
|
||||
}
|
||||
|
||||
// Used by gree
|
||||
pub fn transfer_code_exists(token: &str) -> bool {
|
||||
let database = userdata::get_userdata_database();
|
||||
database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok()
|
||||
|| valid_card_id(token).and_then(|card| card_user(&card)).is_some()
|
||||
}
|
||||
|
||||
pub fn valid_card_id(card_id: &str) -> Option<String> {
|
||||
let card_id = card_id.trim().to_string();
|
||||
if card_id.is_empty() || card_id.len() > 32 || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
Some(card_id)
|
||||
}
|
||||
|
||||
pub fn card_user(card_id: &str) -> Option<i64> {
|
||||
userdata::get_userdata_database().lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
|
||||
}
|
||||
|
||||
pub fn cards_of_account(user_id: i64) -> Vec<String> {
|
||||
let Ok(conn) = rusqlite::Connection::open(userdata::get_userdata_database().get_path()) else { return Vec::new(); };
|
||||
let Ok(mut stmt) = conn.prepare("SELECT card_id FROM cards WHERE user_id=?1 ORDER BY created ASC") else { return Vec::new(); };
|
||||
let Ok(rows) = stmt.query_map(params!(user_id), |row| row.get::<usize, String>(0)) else { return Vec::new(); };
|
||||
rows.flatten().collect()
|
||||
}
|
||||
|
||||
pub fn account_has_card(user_id: i64) -> bool {
|
||||
userdata::get_userdata_database().lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok()
|
||||
}
|
||||
|
||||
pub fn set_card(card_id: &str, user_id: i64) {
|
||||
userdata::get_userdata_database().lock_and_exec(
|
||||
"INSERT INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3) ON CONFLICT(card_id) DO UPDATE SET user_id=?2",
|
||||
params!(card_id, user_id, crate::router::global::timestamp() as i64)
|
||||
);
|
||||
}
|
||||
|
||||
pub fn import_card(card_id: &str, user_id: i64, created: i64) {
|
||||
userdata::get_userdata_database().lock_and_exec(
|
||||
"INSERT OR IGNORE INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3)",
|
||||
params!(card_id, user_id, created)
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove_card(card_id: &str) {
|
||||
userdata::get_userdata_database().lock_and_exec("DELETE FROM cards WHERE card_id=?1", params!(card_id));
|
||||
}
|
||||
|
||||
pub fn remove_card_of(card_id: &str, user_id: i64) -> bool {
|
||||
match card_user(card_id) {
|
||||
Some(owner) if owner == user_id => {
|
||||
remove_card(card_id);
|
||||
true
|
||||
}
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
// The one rule for linking a card, whoever proved the account (game session, webui session, cabinet transfer pair)
|
||||
pub fn link_card(card_id: &str, user_id: i64) -> Result<(String, bool), String> {
|
||||
let Some(card) = valid_card_id(card_id) else {
|
||||
return Err(String::from("That is not a usable card id"));
|
||||
};
|
||||
if crate::router::arcade::is_cabinet_account(user_id) {
|
||||
return Err(String::from("That account belongs to an arcade cabinet"));
|
||||
}
|
||||
let previous = card_user(&card);
|
||||
set_card(&card, user_id);
|
||||
crate::router::arcade::card_relinked(&card);
|
||||
Ok((card, previous.is_some_and(|p| p != user_id)))
|
||||
}
|
||||
|
||||
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
|
||||
@@ -91,6 +177,13 @@ pub fn setup_sql(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
||||
PRIMARY KEY (user_id, token)
|
||||
);
|
||||
", [])?;
|
||||
conn.execute("
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
card_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
created BIGINT NOT NULL
|
||||
);
|
||||
", [])?;
|
||||
let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok();
|
||||
if is_updated { return Ok(()); }
|
||||
println!("Upgrading migration table");
|
||||
@@ -155,3 +248,101 @@ fn legacy_verify_password(password: &str, salted_hash: &str) -> bool {
|
||||
let input_hash = hasher.finalize();
|
||||
input_hash.as_slice() == hashed_password
|
||||
}
|
||||
|
||||
// whenever an ai touches this codebase it decides to write 200000 lines of tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_account_lists_and_unlinks_only_its_own_cards() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
|
||||
let (mine, _) = userdata::starter::create("Mine").unwrap();
|
||||
let (theirs, _) = userdata::starter::create("Theirs").unwrap();
|
||||
assert!(cards_of_account(mine).is_empty());
|
||||
|
||||
set_card("7020392000000011", mine);
|
||||
set_card("7020392000000012", mine);
|
||||
set_card("7020392000000013", theirs);
|
||||
assert_eq!(cards_of_account(mine), vec!["7020392000000011".to_string(), "7020392000000012".to_string()]);
|
||||
assert_eq!(cards_of_account(theirs), vec!["7020392000000013".to_string()]);
|
||||
assert!(account_has_card(mine));
|
||||
|
||||
assert!(!remove_card_of("7020392000000013", mine));
|
||||
assert_eq!(card_user("7020392000000013"), Some(theirs));
|
||||
assert!(!remove_card_of("7020392000000014", mine));
|
||||
|
||||
assert!(remove_card_of("7020392000000011", mine));
|
||||
assert!(card_user("7020392000000011").is_none());
|
||||
assert_eq!(cards_of_account(mine), vec!["7020392000000012".to_string()]);
|
||||
|
||||
remove_card("7020392000000012");
|
||||
remove_card("7020392000000013");
|
||||
assert!(!account_has_card(mine));
|
||||
userdata::delete_account(mine);
|
||||
userdata::delete_account(theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_ids_are_validated_not_sanitised() {
|
||||
assert_eq!(valid_card_id("0123456789012345").as_deref(), Some("0123456789012345"));
|
||||
assert_eq!(valid_card_id(" 0123456789012345 ").as_deref(), Some("0123456789012345"));
|
||||
assert!(valid_card_id("").is_none());
|
||||
assert!(valid_card_id("0123-4567").is_none());
|
||||
assert!(valid_card_id("'; DROP TABLE cards--").is_none());
|
||||
assert!(valid_card_id(&"x".repeat(33)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_card_repoints_a_linked_card_and_says_so() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
|
||||
let (first, _) = userdata::starter::create("First").unwrap();
|
||||
let (second, _) = userdata::starter::create("Second").unwrap();
|
||||
let card = "7020392000000031";
|
||||
|
||||
assert!(link_card("7020-3920", first).is_err());
|
||||
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
|
||||
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
|
||||
assert_eq!(link_card(card, second), Ok((card.to_string(), true)));
|
||||
assert_eq!(card_user(card), Some(second));
|
||||
assert!(!userdata::get_acc_from_uid(first)["error"].as_bool().unwrap_or(false));
|
||||
|
||||
remove_card(card);
|
||||
userdata::delete_account(first);
|
||||
userdata::delete_account(second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_card_is_a_transfer_code_with_the_password_still_required() {
|
||||
let _lock = crate::runtime::lock_test_data_path();
|
||||
|
||||
let (player, token) = userdata::starter::create("Card Transfer").unwrap();
|
||||
let card = "7020392000000021";
|
||||
|
||||
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
|
||||
assert!(!transfer_code_exists(card));
|
||||
|
||||
set_card(card, player);
|
||||
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
|
||||
assert!(!get_acc_transfer(card, "")["success"].as_bool().unwrap());
|
||||
assert!(transfer_code_exists(card));
|
||||
|
||||
save_acc_transfer(player, "hunter2");
|
||||
let by_card = get_acc_transfer(card, "hunter2");
|
||||
assert!(by_card["success"].as_bool().unwrap());
|
||||
assert_eq!(by_card["user_id"].as_i64(), Some(player));
|
||||
assert_eq!(by_card["login_token"].as_str(), Some(token.as_str()));
|
||||
assert!(!get_acc_transfer(card, "wrong")["success"].as_bool().unwrap());
|
||||
let code = get_acc_token(player);
|
||||
assert!(get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap());
|
||||
assert!(!code.chars().all(|c| c.is_ascii_digit()));
|
||||
|
||||
remove_card(card);
|
||||
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
|
||||
|
||||
userdata::delete_account(player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,521 @@
|
||||
use actix_web::{HttpResponse, HttpRequest};
|
||||
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
|
||||
use actix_multipart::Multipart;
|
||||
use futures_util::TryStreamExt;
|
||||
use jzon::{array, object, JsonValue};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn announcement(_req: HttpRequest) -> HttpResponse {
|
||||
use crate::router::{global, userdata, webui};
|
||||
use crate::database::{announcements, permissions};
|
||||
use crate::database::announcements::Banner;
|
||||
|
||||
HttpResponse::Ok().body("sif2 is back!")
|
||||
static ASSETS: Dir<'_> = include_dir!("web_assets/announcement/");
|
||||
|
||||
const MAX_BANNER_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MAX_BANNER_DIM: u32 = 8192;
|
||||
const MAX_SCALED_PIXELS: u64 = 16 * 1024 * 1024;
|
||||
const BANNER_W: u32 = 420;
|
||||
const BANNER_H: u32 = 168;
|
||||
|
||||
const CATEGORY_LABELS: &[(i64, &str, &str)] = &[
|
||||
(1, "notice", "お知らせ"),
|
||||
(2, "update", "アップデート"),
|
||||
(3, "bug", "不具合")
|
||||
];
|
||||
|
||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/web/announcement")
|
||||
.route("", web::get().to(list))
|
||||
.route("/detail", web::get().to(detail))
|
||||
.route("/bulkRead", web::get().to(bulk_read))
|
||||
.route("/assets/{file}", web::get().to(asset))
|
||||
.route("/banner/{id}", web::get().to(banner_image))
|
||||
);
|
||||
cfg.service(
|
||||
web::scope("/announcement")
|
||||
.route("/list", web::get().to(admin_list))
|
||||
.route("/create", web::post().to(create))
|
||||
.route("/update", web::post().to(update))
|
||||
.route("/delete", web::post().to(delete))
|
||||
.route("/banner/{id}", web::get().to(admin_banner_image))
|
||||
);
|
||||
}
|
||||
|
||||
fn disabled() -> bool {
|
||||
crate::get_args().hidden
|
||||
}
|
||||
|
||||
fn query_i64(req: &HttpRequest, key: &str, def: i64) -> i64 {
|
||||
req.query_string()
|
||||
.split('&')
|
||||
.find(|s| s.starts_with(&format!("{key}=")))
|
||||
.and_then(|s| s.split('=').nth(1))
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(def)
|
||||
}
|
||||
|
||||
fn player_key(req: &HttpRequest) -> Option<String> {
|
||||
let key = global::get_login(req.headers(), "");
|
||||
if !key.is_empty() {
|
||||
return Some(key);
|
||||
}
|
||||
let uid = global::get_uid(req.headers());
|
||||
if uid == 0 {
|
||||
return None;
|
||||
}
|
||||
let key = userdata::get_login_token(uid);
|
||||
if key.is_empty() { None } else { Some(key) }
|
||||
}
|
||||
|
||||
fn read_set(req: &HttpRequest) -> HashSet<i64> {
|
||||
match player_key(req) {
|
||||
Some(key) => userdata::get_acc_home(&key)["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect(),
|
||||
None => HashSet::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_read(key: &str, ids: &[i64]) {
|
||||
let mut user = userdata::get_acc_home(key);
|
||||
let mut set: HashSet<i64> = user["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect();
|
||||
for id in ids {
|
||||
set.insert(*id);
|
||||
}
|
||||
let mut sorted: Vec<i64> = set.into_iter().collect();
|
||||
sorted.sort();
|
||||
let mut arr = array![];
|
||||
for id in sorted {
|
||||
arr.push(id).unwrap();
|
||||
}
|
||||
user["home"]["read_announcement_ids"] = arr;
|
||||
userdata::save_acc_home(key, user);
|
||||
}
|
||||
|
||||
fn display_date(published_at: i64) -> String {
|
||||
if published_at <= 0 {
|
||||
return String::new();
|
||||
}
|
||||
let s = global::format_datetime(published_at as u64);
|
||||
format!("{}/{}/{} {}", &s[0..4], &s[5..7], &s[8..10], &s[11..16])
|
||||
}
|
||||
|
||||
fn page_head() -> String {
|
||||
String::from(r#"<!DOCTYPE html>n<html lang="ja-JP"><head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=320, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="stylesheet" href="/web/announcement/assets/sanitize.css" type="text/css">
|
||||
<link rel="stylesheet" href="/web/announcement/assets/news_common.css" type="text/css">
|
||||
<style>#tab div.on{background-image:none;background-color:#f93981;border-radius:0.6vw;}#news_list hr,#detail hr{background-image:none;background-color:#dfe6e6;}</style>
|
||||
<script src="/web/announcement/assets/jquery-3.6.0.min.js"></script>
|
||||
<script>var clicked=false;$(function(){$("a.once").on('click',function(){if(clicked){return false;}clicked=true;return true;});});window.addEventListener('pageshow',function(e){if(e.persisted){clicked=false;}});</script>
|
||||
</head><body>"#)
|
||||
}
|
||||
|
||||
fn page_foot() -> String {
|
||||
String::from("</body></html>")
|
||||
}
|
||||
|
||||
fn tab_bar(active: i64, read: &HashSet<i64>, bulk: bool) -> String {
|
||||
let mut tabs = String::new();
|
||||
for (cat, class, label) in CATEGORY_LABELS {
|
||||
let unread = !bulk && announcements::visible_ids(Some(*cat)).iter().any(|id| !read.contains(id));
|
||||
let badge = if unread {
|
||||
String::from("<span class=\"new\"><img class=\"bg_badge_eff\" src=\"/web/announcement/assets/news_bg_badge_eff.png\"><img class=\"bg_badge\" src=\"/web/announcement/assets/news_bg_badge.png\"></span>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let inner = if *cat == active {
|
||||
format!("<div class=\"on\">{label}</div>")
|
||||
} else {
|
||||
format!("<a class=\"once\" href=\"/web/announcement?category={cat}\">{label}</a>")
|
||||
};
|
||||
tabs.push_str(&format!("<div class=\"{class}\">{badge}{inner}</div>"));
|
||||
}
|
||||
let read_btn = if bulk {
|
||||
String::from("<div class=\"read_btn\"><img class=\"off\" src=\"/web/announcement/assets/news_btn_bulk.png\"></div>")
|
||||
} else {
|
||||
format!("<div class=\"read_btn\"><a class=\"once\" href=\"/web/announcement/bulkRead?category={active}&page=1\"><img src=\"/web/announcement/assets/news_btn_bulk.png\"></a></div>")
|
||||
};
|
||||
format!("<div id=\"header\"><div id=\"tab\">{tabs}{read_btn}</div></div><div id=\"tab_bottom\"></div>")
|
||||
}
|
||||
|
||||
fn render_list(category: i64, read: &HashSet<i64>, bulk: bool) -> String {
|
||||
let mut list = String::new();
|
||||
let items = announcements::list_category(category);
|
||||
if items.is_empty() {
|
||||
list.push_str(&format!(r#"
|
||||
<div class="info_title"><span class="title_text">No announcements right now</span></div>
|
||||
"#));
|
||||
}
|
||||
|
||||
for item in items.members() {
|
||||
let id = item["id"].as_i64().unwrap_or(0);
|
||||
let banner_url = if item["has_banner"].as_bool().unwrap_or(false) {
|
||||
format!("/web/announcement/banner/{id}.png")
|
||||
} else {
|
||||
String::from("/web/announcement/assets/news_banner_generic_news.png")
|
||||
};
|
||||
let kind = item["type"].as_str().unwrap_or("news");
|
||||
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
|
||||
let update_text = if item["updated"].as_bool().unwrap_or(false) { "<span class=\"update_text\">- update</span>" } else { "" };
|
||||
let new_badge = if !bulk && !read.contains(&id) {
|
||||
String::from("<div class=\"info_new_image\"><img class=\"new\" src=\"/web/announcement/assets/news_icon_new.png\"></div>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let title = item["title"].as_str().unwrap_or("");
|
||||
list.push_str(&format!(r#"
|
||||
<li class="list_area"><div class="information_area"><div id="{id}" class="anchor"></div>
|
||||
<a href="/web/announcement/detail?announcement_id={id}&page=1" class="news">
|
||||
<div class="main_image"><span class="banner"><img src="{banner_url}"></span></div>
|
||||
<div class="information">
|
||||
<div class="info_type_image"><img class="tag" src="/web/announcement/assets/news_icon_{kind}.png"></div>
|
||||
<div class="info_date"><span class="date_text">{date}</span>{update_text}</div>
|
||||
{new_badge}
|
||||
<div class="clear"></div>
|
||||
<div class="info_title"><span class="title_text">{title}</span></div>
|
||||
</div>
|
||||
<div class="arrow_image"><img class="arrow" src="/web/announcement/assets/news_img_arrow.png"></div>
|
||||
</a></div><div class="clear"></div><hr></li>
|
||||
"#));
|
||||
}
|
||||
|
||||
format!("{}{}<div id=\"news_list\"><ul>{}</ul><div id=\"page_area\"><div id=\"paging\"><span class=\"page off\">1</span></div></div></div>{}",
|
||||
page_head(), tab_bar(category, read, bulk), list, page_foot())
|
||||
}
|
||||
|
||||
fn render_detail(item: &JsonValue, read: &HashSet<i64>) -> String {
|
||||
let category = item["category"].as_i64().unwrap_or(1);
|
||||
let title = item["title"].as_str().unwrap_or("");
|
||||
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
|
||||
let body = item["body"].as_str().unwrap_or("");
|
||||
let banner = if item["has_banner"].as_bool().unwrap_or(false) {
|
||||
let id = item["id"].as_i64().unwrap_or(0);
|
||||
format!("<div class=\"detail_image\" style=\"display:block\"><img src=\"/web/announcement/banner/{id}.png\"></div>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{}{}<div id=\"detail\"><div class=\"detail_text\"><div class=\"title\">{title}</div><div class=\"date\">{date}</div>{banner}<hr class=\"hr_detail\">{body}</div></div>{}",
|
||||
page_head(), tab_bar(category, read, false), page_foot())
|
||||
}
|
||||
|
||||
fn html(body: String) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::html())
|
||||
.body(body)
|
||||
}
|
||||
|
||||
fn redirect_list(category: i64) -> HttpResponse {
|
||||
HttpResponse::Found()
|
||||
.insert_header(("Location", format!("/web/announcement?category={category}")))
|
||||
.body("")
|
||||
}
|
||||
|
||||
async fn list(req: HttpRequest) -> HttpResponse {
|
||||
let category = query_i64(&req, "category", 1);
|
||||
let category = if announcements::is_valid_category(category) { category } else { 1 };
|
||||
html(render_list(category, &read_set(&req), false))
|
||||
}
|
||||
|
||||
async fn detail(req: HttpRequest) -> HttpResponse {
|
||||
let id = query_i64(&req, "announcement_id", 0);
|
||||
let Some(item) = announcements::get(id) else {
|
||||
return redirect_list(1);
|
||||
};
|
||||
if !item["visible"].as_bool().unwrap_or(false) {
|
||||
return redirect_list(item["category"].as_i64().unwrap_or(1));
|
||||
}
|
||||
if let Some(key) = player_key(&req) {
|
||||
mark_read(&key, &[id]);
|
||||
}
|
||||
html(render_detail(&item, &read_set(&req)))
|
||||
}
|
||||
|
||||
async fn bulk_read(req: HttpRequest) -> HttpResponse {
|
||||
let category = query_i64(&req, "category", 1);
|
||||
let category = if announcements::is_valid_category(category) { category } else { 1 };
|
||||
if let Some(key) = player_key(&req) {
|
||||
mark_read(&key, &announcements::visible_ids(Some(category)));
|
||||
}
|
||||
html(render_list(category, &read_set(&req), true))
|
||||
}
|
||||
|
||||
async fn asset(req: HttpRequest) -> HttpResponse {
|
||||
let file = req.match_info().get("file").unwrap_or("");
|
||||
let Some(file) = ASSETS.get_file(file) else {
|
||||
return HttpResponse::NotFound().finish();
|
||||
};
|
||||
let body = file.contents();
|
||||
let mime = mime_guess::from_path(file.path()).first_or_octet_stream();
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType(mime))
|
||||
.insert_header(("content-length", body.len()))
|
||||
.body(body)
|
||||
}
|
||||
|
||||
fn png_response(bytes: Option<Vec<u8>>) -> HttpResponse {
|
||||
match bytes {
|
||||
Some(bytes) => HttpResponse::Ok()
|
||||
.insert_header(ContentType::png())
|
||||
.insert_header(("content-length", bytes.len()))
|
||||
.body(bytes),
|
||||
None => HttpResponse::NotFound().finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn banner_id(req: &HttpRequest) -> i64 {
|
||||
req.match_info().get("id").unwrap_or("").trim_end_matches(".png").parse::<i64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn banner_image(req: HttpRequest) -> HttpResponse {
|
||||
png_response(announcements::get_public_banner(banner_id(&req)))
|
||||
}
|
||||
|
||||
async fn admin_banner_image(req: HttpRequest) -> HttpResponse {
|
||||
if disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
if manager_uid(&req).filter(|uid| permissions::has(*uid, permissions::ANNOUNCEMENT_MANAGE)).is_none() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
png_response(announcements::get_banner(banner_id(&req)))
|
||||
}
|
||||
|
||||
type Fields = HashMap<String, Vec<u8>>;
|
||||
|
||||
async fn read_multipart(mut payload: Multipart) -> Result<Fields, String> {
|
||||
let mut fields = Fields::new();
|
||||
let mut total = 0usize;
|
||||
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
|
||||
total += chunk.len();
|
||||
if total > MAX_BANNER_BYTES {
|
||||
return Err(format!("Upload exceeds the {} MB limit", MAX_BANNER_BYTES / (1024 * 1024)));
|
||||
}
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
fields.insert(name, data);
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn field_str(fields: &Fields, key: &str) -> String {
|
||||
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
|
||||
}
|
||||
|
||||
fn field_flag(fields: &Fields, key: &str) -> bool {
|
||||
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
|
||||
}
|
||||
|
||||
fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec<u8>> {
|
||||
fields.get(key).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn process_banner(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let img = image::load_from_memory(bytes).map_err(|_| String::from("The banner is not a decodable image (png, jpg and webp work)"))?;
|
||||
if img.width() > MAX_BANNER_DIM || img.height() > MAX_BANNER_DIM {
|
||||
return Err(format!("The banner is {}x{} - neither side may exceed {}px", img.width(), img.height(), MAX_BANNER_DIM));
|
||||
}
|
||||
let img = img.to_rgba8();
|
||||
let scale = f64::max(BANNER_W as f64 / img.width() as f64, BANNER_H as f64 / img.height() as f64);
|
||||
let scaled_w = ((img.width() as f64 * scale).round() as u32).max(1);
|
||||
let scaled_h = ((img.height() as f64 * scale).round() as u32).max(1);
|
||||
if (scaled_w as u64) * (scaled_h as u64) > MAX_SCALED_PIXELS {
|
||||
return Err(format!("The banner is too far from the {}x{} banner shape to be cropped", BANNER_W, BANNER_H));
|
||||
}
|
||||
let scaled = image::imageops::resize(&img, scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
|
||||
let x = (scaled.width() - BANNER_W.min(scaled.width())) / 2;
|
||||
let y = (scaled.height() - BANNER_H.min(scaled.height())) / 2;
|
||||
let cropped = image::imageops::crop_imm(&scaled, x, y, BANNER_W, BANNER_H).to_image();
|
||||
let mut rv = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(cropped).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(rv)
|
||||
}
|
||||
|
||||
fn send_json(resp: JsonValue) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
fn manager_uid(req: &HttpRequest) -> Option<i64> {
|
||||
let token = webui::get_login_token(req)?;
|
||||
let login_token = userdata::webui_login_token(&token)?;
|
||||
userdata::get_acc(&login_token)["user"]["id"].as_i64()
|
||||
}
|
||||
|
||||
fn require_manager(req: &HttpRequest) -> Result<i64, HttpResponse> {
|
||||
if disabled() {
|
||||
return Err(HttpResponse::NotFound().finish());
|
||||
}
|
||||
let Some(uid) = manager_uid(req) else {
|
||||
return Err(webui::error("Not logged in"));
|
||||
};
|
||||
if !permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) {
|
||||
return Err(webui::error("You do not have permission to manage announcements"));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
async fn admin_list(req: HttpRequest) -> HttpResponse {
|
||||
if let Err(resp) = require_manager(&req) {
|
||||
return resp;
|
||||
}
|
||||
let mut categories = array![];
|
||||
for (id, key, label) in CATEGORY_LABELS {
|
||||
categories.push(object!{ id: *id, key: *key, label: *label }).unwrap();
|
||||
}
|
||||
let mut types = array![];
|
||||
for kind in announcements::TYPES {
|
||||
types.push(*kind).unwrap();
|
||||
}
|
||||
send_json(object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
announcements: announcements::get_all(),
|
||||
categories: categories,
|
||||
types: types
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||
let uid = match require_manager(&req) {
|
||||
Ok(uid) => uid,
|
||||
Err(resp) => return resp
|
||||
};
|
||||
let fields = match read_multipart(payload).await {
|
||||
Ok(fields) => fields,
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
match save_new(uid, &fields) {
|
||||
Ok(id) => send_json(object!{ result: "OK", id: id }),
|
||||
Err(e) => webui::error(&e)
|
||||
}
|
||||
}
|
||||
|
||||
fn published_at_of(raw: &str) -> i64 {
|
||||
if raw.is_empty() {
|
||||
global::timestamp() as i64
|
||||
} else {
|
||||
global::parse_datetime(raw).map(|t| t as i64).unwrap_or_else(|| global::timestamp() as i64)
|
||||
}
|
||||
}
|
||||
|
||||
fn save_new(uid: i64, fields: &Fields) -> Result<i64, String> {
|
||||
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
|
||||
if !announcements::is_valid_category(category) {
|
||||
return Err(String::from("Invalid category"));
|
||||
}
|
||||
let kind = field_str(fields, "type");
|
||||
if !announcements::is_valid_type(&kind) {
|
||||
return Err(String::from("Invalid type"));
|
||||
}
|
||||
let title = field_str(fields, "title");
|
||||
if title.is_empty() {
|
||||
return Err(String::from("A title is required"));
|
||||
}
|
||||
let body = field_str(fields, "body");
|
||||
let banner = match file_of(fields, "banner") {
|
||||
Some(bytes) => Some(process_banner(bytes)?),
|
||||
None => None
|
||||
};
|
||||
Ok(announcements::create(category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at_of(&field_str(fields, "published_at")), uid))
|
||||
}
|
||||
|
||||
async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||
if let Err(resp) = require_manager(&req) {
|
||||
return resp;
|
||||
}
|
||||
let fields = match read_multipart(payload).await {
|
||||
Ok(fields) => fields,
|
||||
Err(e) => return webui::error(&e)
|
||||
};
|
||||
match save_update(&fields) {
|
||||
Ok(id) => send_json(object!{ result: "OK", id: id }),
|
||||
Err(e) => webui::error(&e)
|
||||
}
|
||||
}
|
||||
|
||||
fn save_update(fields: &Fields) -> Result<i64, String> {
|
||||
let id = field_str(fields, "id").parse::<i64>().unwrap_or(0);
|
||||
let Some(stored) = announcements::get(id) else {
|
||||
return Err(String::from("That announcement no longer exists"));
|
||||
};
|
||||
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
|
||||
if !announcements::is_valid_category(category) {
|
||||
return Err(String::from("Invalid category"));
|
||||
}
|
||||
let kind = field_str(fields, "type");
|
||||
if !announcements::is_valid_type(&kind) {
|
||||
return Err(String::from("Invalid type"));
|
||||
}
|
||||
let title = field_str(fields, "title");
|
||||
if title.is_empty() {
|
||||
return Err(String::from("A title is required"));
|
||||
}
|
||||
let body = field_str(fields, "body");
|
||||
let banner = if let Some(bytes) = file_of(fields, "banner") {
|
||||
Banner::Set(process_banner(bytes)?)
|
||||
} else if field_flag(fields, "remove_banner") {
|
||||
Banner::Clear
|
||||
} else {
|
||||
Banner::Keep
|
||||
};
|
||||
let published_at = if field_str(fields, "published_at").is_empty() {
|
||||
stored["published_at"].as_i64().unwrap_or_else(|| global::timestamp() as i64)
|
||||
} else {
|
||||
published_at_of(&field_str(fields, "published_at"))
|
||||
};
|
||||
announcements::update(id, category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
|
||||
if let Err(resp) = require_manager(&req) {
|
||||
return resp;
|
||||
}
|
||||
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||
let id = body["id"].as_i64().unwrap_or(0);
|
||||
if announcements::get(id).is_none() {
|
||||
return webui::error("That announcement no longer exists");
|
||||
}
|
||||
announcements::delete(id);
|
||||
send_json(object!{ result: "OK" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([120, 90, 200, 255]));
|
||||
let mut rv = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
|
||||
rv
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banners_are_cropped_to_the_official_size() {
|
||||
for (w, h) in [(420, 168), (1200, 300), (300, 1200), (64, 64)] {
|
||||
let out = process_banner(&png(w, h)).unwrap();
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (BANNER_W, BANNER_H), "source {}x{}", w, h);
|
||||
}
|
||||
assert!(process_banner(b"not an image").unwrap_err().contains("not a decodable image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_sources_are_refused_before_the_resize_allocates() {
|
||||
let err = process_banner(&png(9000, 32)).unwrap_err();
|
||||
assert!(err.contains("9000x32"), "got {}", err);
|
||||
|
||||
let err = process_banner(&png(24, 6000)).unwrap_err();
|
||||
assert!(err.contains("banner shape"), "got {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,13 @@ fn get_config() -> JsonValue {
|
||||
pub fn get_login_token(req: &HttpRequest) -> Option<String> {
|
||||
let blank_header = HeaderValue::from_static("");
|
||||
let cookies = req.headers().get("Cookie").unwrap_or(&blank_header).to_str().unwrap_or("");
|
||||
if cookies.is_empty() {
|
||||
return None;
|
||||
for pair in cookies.split(';') {
|
||||
let Some((name, value)) = pair.split_once('=') else { continue; };
|
||||
if name.trim() == "ew_token" {
|
||||
return Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
|
||||
None
|
||||
}
|
||||
|
||||
fn session_uid(req: &HttpRequest) -> Option<i64> {
|
||||
@@ -229,6 +232,8 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
|
||||
account_import: get_config()["import"].as_bool().unwrap(),
|
||||
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,
|
||||
@@ -367,9 +372,6 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
// The selectable character list for the custom-card form: every official
|
||||
// and SIF1-imported character in the baked csv, by name. The import band
|
||||
// starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014
|
||||
static ref CHARACTER_CHOICES: JsonValue = {
|
||||
let mut rv = jzon::array![];
|
||||
for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
|
||||
@@ -384,8 +386,6 @@ lazy_static! {
|
||||
rv
|
||||
};
|
||||
|
||||
// The skill_center table with its display strings, for picking a center
|
||||
// skill by name instead of by raw id
|
||||
static ref SKILL_CENTER_CHOICES: JsonValue = {
|
||||
let mut en_rows = object!{};
|
||||
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
|
||||
@@ -406,9 +406,6 @@ lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
// The characters a card upload may reference, for the webui's searchable
|
||||
// picker: the baked official + imported list, plus the custom characters
|
||||
// this session may build on (their own and the publicly visible ones)
|
||||
pub fn list_characters(req: HttpRequest) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
@@ -446,8 +443,6 @@ pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array
|
||||
// lengths) so the form enforces them before submitting
|
||||
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
|
||||
if session_uid(&req).is_none() {
|
||||
return error("Not logged in");
|
||||
@@ -461,8 +456,22 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
// The requesting user's own effective scopes, for webui nav gating. Any
|
||||
// session may ask - it only ever reveals what the user themselves holds
|
||||
pub fn custom_3dmv_limits(req: HttpRequest) -> HttpResponse {
|
||||
if crate::router::custom_3dmv::disabled() {
|
||||
return HttpResponse::NotFound().finish();
|
||||
}
|
||||
if session_uid(&req).is_none() {
|
||||
return error("Not logged in");
|
||||
}
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: crate::router::custom_3dmv::upload_limits()
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
@@ -471,12 +480,19 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
||||
result: "OK",
|
||||
data: {
|
||||
uid: uid,
|
||||
scopes: permissions::scopes_for(uid),
|
||||
scopes: permissions::get_user_permissions(uid),
|
||||
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
|
||||
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
|
||||
can_manage_groups: permissions::has(uid, permissions::GROUP_MANAGE),
|
||||
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
|
||||
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
|
||||
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|
||||
|| permissions::has(uid, permissions::PERMISSION_REVOKE)
|
||||
|| permissions::has(uid, permissions::PERMISSION_REVOKE),
|
||||
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE),
|
||||
// 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()
|
||||
@@ -484,8 +500,6 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
// The admin view: every grant plus the grantable vocabulary. Needs a
|
||||
// permission.* scope - my_scopes is the anyone-can-ask endpoint
|
||||
pub fn list_permissions(req: HttpRequest) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
@@ -505,7 +519,7 @@ pub fn list_permissions(req: HttpRequest) -> HttpResponse {
|
||||
uid: uid,
|
||||
can_grant: can_grant,
|
||||
can_revoke: can_revoke,
|
||||
scopes: permissions::scopes_for(uid),
|
||||
scopes: permissions::get_user_permissions(uid),
|
||||
available: available,
|
||||
grants: permissions::grants()
|
||||
}
|
||||
@@ -552,6 +566,110 @@ 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))
|
||||
}
|
||||
|
||||
pub fn link_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
};
|
||||
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||
match crate::router::userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), uid) {
|
||||
Ok((card_id, rebound)) => {
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
card_id: card_id,
|
||||
rebound: rebound
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
},
|
||||
Err(reason) => error(&reason)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_nesica_cards(req: HttpRequest) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
};
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
card_ids: crate::router::userdata::user::migration::cards_of_account(uid)
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
pub fn unlink_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
|
||||
let Some(uid) = session_uid(&req) else {
|
||||
return error("Not logged in");
|
||||
};
|
||||
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||
let Some(card) = crate::router::userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
|
||||
return error("That is not a usable card id");
|
||||
};
|
||||
if !crate::router::userdata::user::migration::remove_card_of(&card, uid) {
|
||||
return error("That card is not linked to this account");
|
||||
}
|
||||
let resp = object!{
|
||||
result: "OK",
|
||||
data: {
|
||||
card_id: card
|
||||
}
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.insert_header(ContentType::json())
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
||||
let token = get_login_token(&req);
|
||||
if token.is_none() {
|
||||
@@ -592,10 +710,37 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
||||
.body(jzon::stringify(resp))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// rest of file is tests that ai wrote
|
||||
// I didn't read through them because I don't super care about tests but they probably do something
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// get_login_token is the ONLY authentication on every mutating custom-content
|
||||
// route, so it has to read the cookie header as cookies, not as a substring:
|
||||
// a name that merely ends in ew_token, or a second ew_token= appended later,
|
||||
// must not win over the real session cookie
|
||||
#[test]
|
||||
fn the_session_cookie_is_matched_by_name() {
|
||||
let token_for = |header: &str| get_login_token(
|
||||
&actix_web::test::TestRequest::default().insert_header(("Cookie", header)).to_http_request()
|
||||
);
|
||||
|
||||
assert_eq!(token_for("ew_token=real").as_deref(), Some("real"));
|
||||
assert_eq!(token_for("theme=dark; ew_token=real; lang=en").as_deref(), Some("real"));
|
||||
// A cookie whose NAME ends in ew_token is a different cookie
|
||||
assert_eq!(token_for("not_ew_token=attacker").as_deref(), None);
|
||||
assert_eq!(token_for("ew_token=real; xew_token=attacker").as_deref(), Some("real"));
|
||||
// A duplicate set later in the header does not override the first
|
||||
assert_eq!(token_for("ew_token=real; ew_token=attacker").as_deref(), Some("real"));
|
||||
// No cookie at all is no session, not the whole header
|
||||
assert_eq!(token_for("theme=dark").as_deref(), None);
|
||||
assert_eq!(get_login_token(&actix_web::test::TestRequest::default().to_http_request()), None);
|
||||
}
|
||||
|
||||
// The picker lists the card form searches by name: every baked character
|
||||
// (official + SIF1 import, badged apart) and every skill_center row with
|
||||
// its JP and EN display strings
|
||||
|
||||
@@ -23,8 +23,31 @@ pub struct HostConfig {
|
||||
pub port: u16,
|
||||
pub jp_android_asset_hash: String,
|
||||
pub en_android_asset_hash: String,
|
||||
pub asset_version: String,
|
||||
pub en_asset_version: String,
|
||||
pub jp_ios_asset_hash: String,
|
||||
pub en_ios_asset_hash: String,
|
||||
pub windows_asset_hash: String,
|
||||
pub enable_custom_songs: bool,
|
||||
pub enable_custom_cards: bool,
|
||||
pub enable_custom_3dmv: bool,
|
||||
pub enable_arcade: bool,
|
||||
pub nerf_custom_cards: bool,
|
||||
pub owner: Vec<i64>,
|
||||
pub hidden: bool,
|
||||
pub force_updates: bool,
|
||||
pub disable_imports: bool,
|
||||
pub disable_exports: bool,
|
||||
pub linux_asset_hash: String,
|
||||
pub macos_asset_hash: String,
|
||||
pub global_android: String,
|
||||
pub japan_android: String,
|
||||
pub global_ios: String,
|
||||
pub japan_ios: String,
|
||||
pub arcade_machine_ttl: u64,
|
||||
pub arcade_session_ttl: u64,
|
||||
pub image_asset_path: String,
|
||||
pub masterdata: String,
|
||||
}
|
||||
|
||||
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
|
||||
@@ -37,6 +60,24 @@ pub fn set_enable_custom_cards(enabled: bool) {
|
||||
HOST_CONFIG.write().unwrap().enable_custom_cards = enabled;
|
||||
}
|
||||
|
||||
pub fn set_enable_custom_3dmv(enabled: bool) {
|
||||
HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled;
|
||||
}
|
||||
|
||||
pub fn set_enable_arcade(enabled: bool) {
|
||||
HOST_CONFIG.write().unwrap().enable_arcade = enabled;
|
||||
}
|
||||
|
||||
// The custom-card nerf band. Meaningful only with enable_custom_cards; kept
|
||||
// separate so an embedding app can flip them independently
|
||||
pub fn set_nerf_custom_cards(enabled: bool) {
|
||||
HOST_CONFIG.write().unwrap().nerf_custom_cards = enabled;
|
||||
}
|
||||
|
||||
pub fn get_nerf_custom_cards() -> bool {
|
||||
HOST_CONFIG.read().unwrap().nerf_custom_cards
|
||||
}
|
||||
|
||||
// The --owner uids: the permission system's bootstrap grantors. Process-level
|
||||
// state rather than db rows so they work on a fresh install and can't be
|
||||
// revoked through the webui
|
||||
@@ -141,6 +182,38 @@ pub fn apply_config_json(json: &str) {
|
||||
cfg.port = parsed["port"].as_u64().unwrap_or(0) as u16;
|
||||
cfg.jp_android_asset_hash = s("jpAndroidAssetHash");
|
||||
cfg.en_android_asset_hash = s("enAndroidAssetHash");
|
||||
cfg.asset_version = s("assetVersion");
|
||||
cfg.en_asset_version = s("enAssetVersion");
|
||||
cfg.jp_ios_asset_hash = s("jpIosAssetHash");
|
||||
cfg.en_ios_asset_hash = s("enIosAssetHash");
|
||||
cfg.windows_asset_hash = s("windowsAssetHash");
|
||||
cfg.linux_asset_hash = s("linuxAssetHash");
|
||||
cfg.macos_asset_hash = s("macosAssetHash");
|
||||
cfg.global_android = s("globalAndroid");
|
||||
cfg.japan_android = s("japanAndroid");
|
||||
cfg.global_ios = s("globalIos");
|
||||
cfg.japan_ios = s("japanIos");
|
||||
cfg.arcade_machine_ttl = parsed["arcadeMachineTtl"].as_u64().unwrap_or(90);
|
||||
cfg.arcade_session_ttl = parsed["arcadeSessionTtl"].as_u64().unwrap_or(30);
|
||||
cfg.image_asset_path = s("imageAssetPath");
|
||||
cfg.masterdata = s("masterdata");
|
||||
cfg.owner = parsed["ownerUids"].members().filter_map(|v| v.as_i64()).collect();
|
||||
cfg.hidden = parsed["hidden"].as_bool().unwrap_or(false);
|
||||
cfg.force_updates = parsed["forceUpdates"].as_bool().unwrap_or(false);
|
||||
cfg.disable_imports = parsed["disableImports"].as_bool().unwrap_or(false);
|
||||
cfg.disable_exports = parsed["disableExports"].as_bool().unwrap_or(false);
|
||||
cfg.enable_custom_songs = parsed["enableCustomSongs"].as_bool().unwrap_or(false);
|
||||
cfg.enable_custom_cards = parsed["enableCustomCards"].as_bool().unwrap_or(false);
|
||||
cfg.nerf_custom_cards = parsed["nerfCustomCards"].as_bool().unwrap_or(false);
|
||||
cfg.enable_custom_3dmv = parsed["enableCustom3dmv"].as_bool().unwrap_or(false);
|
||||
cfg.enable_arcade = parsed["enableArcade"].as_bool().unwrap_or(false);
|
||||
let owners = cfg.owner.clone();
|
||||
let masterdata = cfg.masterdata.clone();
|
||||
let nerf_custom_cards = cfg.nerf_custom_cards;
|
||||
drop(cfg);
|
||||
update_owners(&owners);
|
||||
update_masterdata_path(&masterdata);
|
||||
set_nerf_custom_cards(nerf_custom_cards);
|
||||
}
|
||||
|
||||
pub fn overlay_args(args: &mut crate::options::Args) {
|
||||
@@ -162,14 +235,51 @@ pub fn overlay_args(args: &mut crate::options::Args) {
|
||||
}
|
||||
overlay_str!(jp_android_asset_hash);
|
||||
overlay_str!(en_android_asset_hash);
|
||||
// Overlay only ever enables the features; a command-line --enable-custom-songs
|
||||
// / --enable-custom-cards is never overridden back to off
|
||||
overlay_str!(asset_version);
|
||||
overlay_str!(en_asset_version);
|
||||
overlay_str!(jp_ios_asset_hash);
|
||||
overlay_str!(en_ios_asset_hash);
|
||||
overlay_str!(windows_asset_hash);
|
||||
overlay_str!(linux_asset_hash);
|
||||
overlay_str!(macos_asset_hash);
|
||||
overlay_str!(global_android);
|
||||
overlay_str!(japan_android);
|
||||
overlay_str!(global_ios);
|
||||
overlay_str!(japan_ios);
|
||||
overlay_str!(image_asset_path);
|
||||
overlay_str!(masterdata);
|
||||
if cfg.arcade_machine_ttl != 0 {
|
||||
args.arcade_machine_ttl = cfg.arcade_machine_ttl;
|
||||
}
|
||||
if cfg.arcade_session_ttl != 0 {
|
||||
args.arcade_session_ttl = cfg.arcade_session_ttl;
|
||||
}
|
||||
// An embedding app may supply these options, but its default values must
|
||||
// not erase flags passed by the standalone server (including Docker).
|
||||
if !cfg.owner.is_empty() {
|
||||
args.owner = cfg.owner.clone();
|
||||
}
|
||||
args.hidden |= cfg.hidden;
|
||||
args.force_updates |= cfg.force_updates;
|
||||
args.disable_imports |= cfg.disable_imports;
|
||||
args.disable_exports |= cfg.disable_exports;
|
||||
// Overlay only ever enables the features; a command-line --enable-custom-*
|
||||
// flag is never overridden back to off
|
||||
if cfg.enable_custom_songs {
|
||||
args.enable_custom_songs = true;
|
||||
}
|
||||
if cfg.enable_custom_cards {
|
||||
args.enable_custom_cards = true;
|
||||
}
|
||||
if cfg.enable_custom_3dmv {
|
||||
args.enable_custom_3dmv = true;
|
||||
}
|
||||
if cfg.enable_arcade {
|
||||
args.enable_arcade = true;
|
||||
}
|
||||
if cfg.nerf_custom_cards {
|
||||
args.nerf_custom_cards = true;
|
||||
}
|
||||
}
|
||||
|
||||
// idk why an ai put tests here but they are here now. Yay tests????
|
||||
@@ -192,5 +302,44 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
|
||||
// while holding the lock
|
||||
set_enable_custom_songs(true);
|
||||
set_enable_custom_cards(true);
|
||||
set_enable_custom_3dmv(true);
|
||||
set_enable_arcade(true);
|
||||
guard
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn android_host_config_updates_owner_and_feature_options() {
|
||||
use clap::Parser;
|
||||
let _guard = lock_test_data_path();
|
||||
apply_config_json(r#"{"ownerUids":[42,84],"hidden":true,"forceUpdates":true,"disableImports":true,"disableExports":true,"enableCustomSongs":true,"enableCustomCards":true,"nerfCustomCards":true,"enableCustom3dmv":true,"enableArcade":true,"linuxAssetHash":"linux-hash","macosAssetHash":"mac-hash","globalAndroid":"https://example.com/gl.apk","arcadeMachineTtl":45,"arcadeSessionTtl":15}"#);
|
||||
let mut args = crate::options::Args::parse_from(["ew"]);
|
||||
overlay_args(&mut args);
|
||||
assert_eq!(get_owners(), vec![42, 84]);
|
||||
assert_eq!(args.owner, vec![42, 84]);
|
||||
assert!(args.hidden && args.force_updates && args.disable_imports && args.disable_exports);
|
||||
assert!(args.enable_custom_songs && args.enable_custom_cards && args.nerf_custom_cards);
|
||||
assert!(get_nerf_custom_cards());
|
||||
assert!(args.enable_custom_3dmv && args.enable_arcade);
|
||||
assert_eq!(args.linux_asset_hash, "linux-hash");
|
||||
assert_eq!(args.macos_asset_hash, "mac-hash");
|
||||
assert_eq!(args.global_android, "https://example.com/gl.apk");
|
||||
assert_eq!(args.arcade_machine_ttl, 45);
|
||||
assert_eq!(args.arcade_session_ttl, 15);
|
||||
|
||||
apply_config_json(r#"{"ownerUids":[],"enableCustomSongs":false}"#);
|
||||
let mut reset = crate::options::Args::parse_from(["ew"]);
|
||||
overlay_args(&mut reset);
|
||||
assert!(get_owners().is_empty());
|
||||
assert!(!reset.enable_custom_songs);
|
||||
assert!(!reset.hidden);
|
||||
assert!(!get_nerf_custom_cards());
|
||||
|
||||
let mut cli = crate::options::Args::parse_from([
|
||||
"ew", "--force-updates", "--hidden", "--disable-imports",
|
||||
"--disable-exports", "--owner", "123",
|
||||
]);
|
||||
overlay_args(&mut cli);
|
||||
assert!(cli.force_updates && cli.hidden && cli.disable_imports && cli.disable_exports);
|
||||
assert_eq!(cli.owner, vec![123]);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ use jzon::{JsonValue, array};
|
||||
pub struct SQLite {
|
||||
path: String
|
||||
}
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
fn open(path: &str) -> Result<Connection, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl SQLite {
|
||||
pub fn new(path: &str, setup: fn(&Connection)) -> SQLite {
|
||||
let instance = SQLite {
|
||||
path: crate::get_data_path(path)
|
||||
};
|
||||
let conn = Connection::open(&instance.path).unwrap();
|
||||
conn.busy_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let conn = open(&instance.path).unwrap();
|
||||
conn.execute("PRAGMA foreign_keys = ON;", ()).unwrap();
|
||||
setup(&conn);
|
||||
instance
|
||||
@@ -20,11 +26,11 @@ impl SQLite {
|
||||
&self.path
|
||||
}
|
||||
pub fn lock_and_exec(&self, command: &str, args: &[&dyn ToSql]) {
|
||||
let conn = Connection::open(&self.path).unwrap();
|
||||
let conn = open(&self.path).unwrap();
|
||||
conn.execute(command, args).unwrap();
|
||||
}
|
||||
pub fn lock_and_select(&self, command: &str, args: &[&dyn ToSql]) -> Result<String, rusqlite::Error> {
|
||||
let conn = Connection::open(&self.path).unwrap();
|
||||
let conn = open(&self.path)?;
|
||||
let mut stmt = conn.prepare(command)?;
|
||||
stmt.query_row(args, |row| {
|
||||
match row.get::<usize, i64>(0) {
|
||||
@@ -34,14 +40,14 @@ impl SQLite {
|
||||
})
|
||||
}
|
||||
pub fn lock_and_select_type<T: rusqlite::types::FromSql>(&self, command: &str, args: &[&dyn ToSql]) -> Result<T, rusqlite::Error> {
|
||||
let conn = Connection::open(&self.path).unwrap();
|
||||
let conn = open(&self.path)?;
|
||||
let mut stmt = conn.prepare(command)?;
|
||||
stmt.query_row(args, |row| {
|
||||
row.get(0)
|
||||
})
|
||||
}
|
||||
pub fn lock_and_select_all(&self, command: &str, args: &[&dyn ToSql]) -> Result<JsonValue, rusqlite::Error> {
|
||||
let conn = Connection::open(&self.path).unwrap();
|
||||
let conn = open(&self.path)?;
|
||||
let mut stmt = conn.prepare(command)?;
|
||||
let map = stmt.query_map(args, |row| {
|
||||
match row.get::<usize, i64>(0) {
|
||||
@@ -60,24 +66,11 @@ impl SQLite {
|
||||
Ok(rv)
|
||||
}
|
||||
|
||||
// Runs a read-modify-write as one unit. Additive on purpose — the other helpers open
|
||||
// a fresh connection per statement, so a caller that SELECTs then INSERTs races any
|
||||
// concurrent caller doing the same and the loser hits a constraint violation (which
|
||||
// lock_and_exec would unwrap into a worker panic).
|
||||
//
|
||||
// BEGIN IMMEDIATE takes the write lock up front rather than at first write, so two
|
||||
// callers serialise instead of both reading the pre-state; busy_timeout makes the
|
||||
// loser wait for the winner rather than fail instantly (SQLite::new sets that on its
|
||||
// own short-lived setup connection, not on the per-call ones).
|
||||
//
|
||||
// Errors are returned, never unwrapped: statistics writes must not take down a
|
||||
// request.
|
||||
pub fn lock_and_transact<T>(
|
||||
&self,
|
||||
f: impl FnOnce(&Connection) -> Result<T, rusqlite::Error>
|
||||
) -> Result<T, rusqlite::Error> {
|
||||
let mut conn = Connection::open(&self.path)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(10))?;
|
||||
let mut conn = open(&self.path)?;
|
||||
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
let rv = f(&tx)?;
|
||||
tx.commit()?;
|
||||
|
||||
@@ -71,7 +71,7 @@ fn platform_guard(ctx: &guard::GuardContext) -> bool {
|
||||
.split('/')
|
||||
.nth(1)
|
||||
.unwrap_or("");
|
||||
matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "WebGL" | "iOS")
|
||||
matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "StandaloneOSX" | "WebGL" | "iOS")
|
||||
}
|
||||
|
||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,465 @@
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
背景設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
|
||||
html {
|
||||
box-sizing: border-box; /* 1 */
|
||||
cursor: default; /* 2 */
|
||||
line-height: 1.5; /* 3 */
|
||||
-ms-text-size-adjust: 100%; /* 4 */
|
||||
-webkit-text-size-adjust: 100%; /* 5 */
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
/* 表示無い個所の処理 */
|
||||
background-repeat: no-repeat; /* 1 */
|
||||
box-sizing: inherit; /* 2 */
|
||||
}
|
||||
body {
|
||||
font-family: 'YuGothic','Yu Gothic','Hiragino Kaku Gothic ProN','ヒラギノ角ゴ ProN W3',sans-serif;
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
color: #494949;
|
||||
background-color: #f5ffff;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
-webkit-touch-callout: none; /* リンク長押しのポップアップを無効化*/
|
||||
-webkit-user-select: none; /* テキスト長押しの選択ボックスを無効化*/
|
||||
-webkit-tap-highlight-color:rgba(0,0,0,0);/*リンクの領域表示を無効化*/
|
||||
}
|
||||
|
||||
/* ダークモード */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #f5ffff;
|
||||
color: #494949;
|
||||
}
|
||||
#header {
|
||||
background-color: #f5ffff;
|
||||
}
|
||||
#tab div.on {
|
||||
color: #f5ffff;
|
||||
}
|
||||
.detail_text {
|
||||
color: #494949;
|
||||
}
|
||||
#tab div a, #tab div a:visited {
|
||||
color: #494949;
|
||||
}
|
||||
#news_list .news .info_date {
|
||||
color: #828282;
|
||||
}
|
||||
}
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
文字大きさ、ポジション 幅1580px基準のvw
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
/* 詳細本文 */
|
||||
/* 40文字×無制限 */
|
||||
.detail_text {
|
||||
font-size: 2.0vw;
|
||||
color: #494949;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 装飾用 */
|
||||
/* <div class="title"> */
|
||||
#detail .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* <span class="bold"> */
|
||||
#detail .bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* <div class="date"> */
|
||||
#detail .date {
|
||||
color: #f93981;
|
||||
}
|
||||
|
||||
/* <span class="pink"> */
|
||||
#detail .pink {
|
||||
color: #f93981;
|
||||
}
|
||||
|
||||
/* 見出し用 *
|
||||
/* 更新日 24px */
|
||||
.date_text {
|
||||
font-size: 1.6vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* タイトル 32px */
|
||||
.title_text {
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.update_text {
|
||||
font-size: 1.3vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#tab {
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
メニュータブ
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
#header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 7.2vw;
|
||||
background-color: #F2FFFF;
|
||||
z-index: 1;
|
||||
}
|
||||
#tab {
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin: 1.6vw 1.3vw;
|
||||
width: 97.4vw;
|
||||
height: 4.0vw;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#tab>div {
|
||||
float: left;
|
||||
width: 18.9vw;
|
||||
height: 4.0vw;
|
||||
color: #494949;
|
||||
text-align: center;
|
||||
line-height: 4.0vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#tab div:nth-child(1), #tab div:nth-child(2) {
|
||||
border-right: #CDD0D0 1px solid;
|
||||
}
|
||||
|
||||
#tab div a, #tab div a:visited {
|
||||
color: #494949;
|
||||
}
|
||||
|
||||
#tab div.on {
|
||||
color: #f5ffff;
|
||||
background-image: url("../0_common_images/news_img_tab.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
#tab a {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: 15.5vw;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#tab .tab_text {
|
||||
text-align: center;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
#tab .new img.bg_badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -0.5vw;
|
||||
right: 1.1vw;
|
||||
width: 2.4vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab .new img.bg_badge_eff {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1.3vw;
|
||||
right: 0.3vw;
|
||||
width: 4.0vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab>div.read_btn, #tab>div.more_btn {
|
||||
float: right;
|
||||
width: 15.6vw;
|
||||
height: 4.0vw;
|
||||
margin: 0vw -0.4vw 0vw 0.4vw;
|
||||
}
|
||||
|
||||
#tab .read_btn img, #tab .more_btn img {
|
||||
width: 15.6vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab img.off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#tab_bottom {
|
||||
margin-top: 7.2vw;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
共通項目
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
/*種類アイコン画像*/
|
||||
.info_type_image img.tag {
|
||||
width : 13.7vw; /* 画像を枠の100%の横幅にする */
|
||||
height: auto; /* 画像の縦幅を自動調整 */
|
||||
}
|
||||
|
||||
.info_type_image img.present {
|
||||
width : 1.5vw;
|
||||
height: auto;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
|
||||
.information_area {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.information {
|
||||
position: relative;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.information img.new {
|
||||
width : 8.3vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
img.arrow {
|
||||
width : 1.8vw;
|
||||
height: auto;
|
||||
margin-top: 2.4vw;
|
||||
margin-left: 1.0vw;
|
||||
margin-right: 0.4vw;
|
||||
}
|
||||
|
||||
img.arrow_back {
|
||||
width : 1.8vw;
|
||||
height: auto;
|
||||
margin-top: 2.4vw;
|
||||
margin-right: 1.4vw;
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
.info_title {
|
||||
margin-top: 0vw;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
position: absolute;
|
||||
margin-top: -7.2vw;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#outer {
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
}
|
||||
|
||||
#outer #center {
|
||||
height:100%;
|
||||
width:100%;
|
||||
display:table;
|
||||
}
|
||||
|
||||
#outer #center p {
|
||||
display: table-cell;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
お知らせリスト設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#news_list .main_image img {
|
||||
width : 20.86vw;
|
||||
height: 8.34vw;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#news_list .news {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
color: #494949;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);/*リンクの領域表示を無効化*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#news_list .news .info_date {
|
||||
color: #828282;
|
||||
}
|
||||
|
||||
#news_list ul {
|
||||
margin: 1.3vw 1.3vw 0;
|
||||
padding: 0;
|
||||
width: 97.4vw;
|
||||
}
|
||||
|
||||
#news_list li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#news_list hr, #detail hr {
|
||||
background-image: url("../0_common_images/news_img_line.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: contain;
|
||||
height: 0.26vw;
|
||||
border: 0;
|
||||
margin-top: 0.8vw;
|
||||
margin-bottom: 0.8vw;
|
||||
}
|
||||
|
||||
#news_list hr.hr_detail {
|
||||
margin-top: 1.1vw;
|
||||
margin-bottom: 1.1vw;
|
||||
|
||||
}
|
||||
|
||||
#news_list .list_area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#news_list .information {
|
||||
float: left;
|
||||
margin-left: 2.0vw;
|
||||
width: 70.5vw;
|
||||
}
|
||||
|
||||
#news_list .info_type_image {
|
||||
float: left;
|
||||
margin-top: 0.8vw;
|
||||
width: 13.7vw;
|
||||
}
|
||||
|
||||
#news_list .info_date {
|
||||
float: left;
|
||||
margin-top: 1.0vw;
|
||||
margin-left: 2.0vw;
|
||||
}
|
||||
|
||||
#news_list .info_new_image {
|
||||
float: right;
|
||||
margin-top: 0.7vw;
|
||||
width: 8.3vw;
|
||||
}
|
||||
|
||||
#news_list .arrow_image {
|
||||
float: left;
|
||||
width: 3.2vw;
|
||||
height: 8.3vw;
|
||||
}
|
||||
|
||||
#page_area {
|
||||
margin-top: 3%;
|
||||
padding: 0 2.0vw 4.0vw 2.0vw;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
font-size: 1.2vw;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
#paging {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#page_area .page {
|
||||
font-size: 1.8vw;
|
||||
font-weight: bold;
|
||||
padding-left: 1.5vw;
|
||||
padding-right: 1.5vw;
|
||||
color: #191919;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#page_area .page.new {
|
||||
color: #c12424;
|
||||
}
|
||||
|
||||
#page_area .page.off {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
詳細ページ設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#detail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#detail .detail_text {
|
||||
width : 97.4vw;
|
||||
height : auto;
|
||||
vertical-align:middle;
|
||||
padding: 0;
|
||||
margin: 0vw 1.3vw 1.3vw;
|
||||
}
|
||||
|
||||
#detail .detail_image {
|
||||
width: 100%;
|
||||
padding: 1.0vw;
|
||||
text-align: center;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
#detail .detail_image img {
|
||||
width: 45.0vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.detail_text img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
外部コンテンツページ設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#contents {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#contents #banner_list {
|
||||
position: relative;
|
||||
left: 3vw;
|
||||
}
|
||||
|
||||
#contents .banner {
|
||||
float: left;
|
||||
padding: 0.6vw;
|
||||
}
|
||||
|
||||
#contents .banner:nth-child(3n+4) {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#contents .clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#contents img {
|
||||
width: 30.0vw;
|
||||
height: auto;
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,550 @@
|
||||
/*! sanitize.css v4.0.0 | CC0 License | github.com/10up/sanitize.css */
|
||||
|
||||
/* Display definitions
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
* 2. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
details, /* 1 */
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
main, /* 2 */
|
||||
menu,
|
||||
nav,
|
||||
section,
|
||||
summary { /* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
progress,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template, /* 1 */
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Elements of HTML (https://www.w3.org/TR/html5/semantics.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove repeating backgrounds in all browsers (opinionated).
|
||||
* 2. Add box sizing inheritence in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
background-repeat: no-repeat; /* 1 */
|
||||
box-sizing: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add text decoration inheritance in all browsers (opinionated).
|
||||
* 2. Add vertical alignment inheritence in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
::before,
|
||||
::after {
|
||||
text-decoration: inherit; /* 1 */
|
||||
vertical-align: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add border box sizing in all browsers (opinionated).
|
||||
* 2. Add the default cursor in all browsers (opinionated).
|
||||
* 3. Add a flattened line height in all browsers (opinionated).
|
||||
* 4. Prevent font size adjustments after orientation changes in IE and iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
box-sizing: border-box; /* 1 */
|
||||
cursor: default; /* 2 */
|
||||
font-family: sans-serif; /* 3 */
|
||||
line-height: 1.5; /* 3 */
|
||||
-ms-text-size-adjust: 100%; /* 4 */
|
||||
-webkit-text-size-adjust: 100%; /* 5 */
|
||||
}
|
||||
|
||||
/* Sections (https://www.w3.org/TR/html5/sections.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font sizes and margins on `h1` elements within
|
||||
* `section` and `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: .67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content (https://www.w3.org/TR/html5/grouping-content.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct font sizing inheritance and scaling in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the height in Firefox.
|
||||
* 2. Add visible overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the list style on navigation lists in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
nav ol,
|
||||
nav ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add a bordered underline effect in all browsers.
|
||||
* 2. Remove text decoration in Firefox 40+.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: 1px dotted; /* 1 */
|
||||
text-decoration: none; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct colors in IE 9-.
|
||||
*/
|
||||
|
||||
mark {
|
||||
background-color: #ffff00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 83.3333%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the positioning on superscript and subscript elements
|
||||
* in all browsers (opinionated).
|
||||
* 1. Correct the font size in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 83.3333%; /* 1 */
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove the text shadow on text selections (opinionated).
|
||||
* 1. Restore the coloring undone by defining the text shadow (opinionated).
|
||||
*/
|
||||
|
||||
::-moz-selection {
|
||||
background-color: #b3d4fc; /* 1 */
|
||||
color: #000000; /* 1 */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #b3d4fc; /* 1 */
|
||||
color: #000000; /* 1 */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Embedded content (https://www.w3.org/TR/html5/embedded-content-0.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Change the alignment on media elements in all browers (opinionated).
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
iframe,
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the fill color to match the text color in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Links (https://www.w3.org/TR/html5/links.html#links)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove the gaps in underlines in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the outline when hovering in all browsers (opinionated.
|
||||
*/
|
||||
|
||||
:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
/* Tabular data (https://www.w3.org/TR/html5/tabular-data.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Remove border spacing in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* transform-style: (https://www.w3.org/TR/html5/forms.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the default styling in all browsers (opinionated).
|
||||
* 3. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
/* button, */
|
||||
input,
|
||||
select
|
||||
/* textarea */
|
||||
{
|
||||
background-color: transparent; /* 1 */
|
||||
border-style: none; /* 1 */
|
||||
color: inherit; /* 1 */
|
||||
font-size: 1em; /* 1 */
|
||||
margin: 0; /* 3 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the overflow in IE.
|
||||
* 1. Correct the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent the WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the border, margin, and padding in all browsers.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #c0c0c0;
|
||||
margin: 0 2px;
|
||||
padding: .35em .625em .75em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the vertical scrollbar in IE.
|
||||
* 2. Change the resize direction on textareas in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto; /* 1 */
|
||||
resize: vertical; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style on increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari for OS X.
|
||||
*/
|
||||
|
||||
::-webkit-search-cancel-button,
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the text style on placeholders in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
color: inherit;
|
||||
opacity: .54;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* WAI-ARIA (https://www.w3.org/TR/html5/dom.html#wai-aria)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Change the cursor on busy elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the cursor on control elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the cursor on disabled, not-editable, or otherwise
|
||||
* inoperable elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* User interaction (https://www.w3.org/TR/html5/editing.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Remove the tapping delay on clickable elements (opinionated).
|
||||
* 1. Remove the tapping delay in IE 10.
|
||||
*/
|
||||
|
||||
a,
|
||||
area,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea,
|
||||
[tabindex] {
|
||||
-ms-touch-action: manipulation; /* 1 */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the display on visually hidden accessible elements (opinionated).
|
||||
*/
|
||||
|
||||
[hidden][aria-hidden="false"] {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
display: inherit;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
[hidden][aria-hidden="false"]:focus {
|
||||
clip: auto;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Maintenance</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #0b0e1a; color: #e8ebf5; font-family: system-ui, sans-serif; }
|
||||
main { max-width: 32rem; padding: 2rem; text-align: center; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
p { color: #c3c9dd; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>The server is down for maintenance</h1>
|
||||
<p>{{message}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||