Compare commits
26 Commits
d8f3c75236
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5228e9f5 | ||
|
|
1d12a9415c | ||
|
|
4dcf73e5d4 | ||
|
|
06c9273151 | ||
|
|
d975b87799 | ||
|
|
cb40d74c2c | ||
|
|
8f7346d09e | ||
|
|
0df2741251 | ||
|
|
cf05ffe8fb | ||
|
|
27d7b8de82 | ||
|
|
75613dafb0 | ||
|
|
239a47b8ce | ||
|
|
214ff68335 | ||
|
|
582d56c301 | ||
|
|
0b0bef7bfb | ||
|
|
29ed7a1b46 | ||
|
|
b4cd6968d8 | ||
|
|
ee1a3d827e | ||
|
|
f75891dea0 | ||
|
|
26926e1a28 | ||
|
|
d077210efe | ||
|
|
5d31cbad29 | ||
|
|
f5bb3a5c7d | ||
|
|
7a4da25358 | ||
|
|
6609a1ae77 | ||
|
|
ebfc15d2b9 |
7
.gitignore
vendored
@@ -11,3 +11,10 @@ docker/data/
|
|||||||
ndk/
|
ndk/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
custom_songs/
|
custom_songs/
|
||||||
|
custom_cards/
|
||||||
|
/custom_3dmv/
|
||||||
|
|
||||||
|
# local-only trees — never commit (35GB between them)
|
||||||
|
/android/
|
||||||
|
/assets.bak/
|
||||||
|
/assets.old/
|
||||||
|
|||||||
19
Cargo.lock
generated
@@ -223,6 +223,22 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-ws"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f"
|
||||||
|
dependencies = [
|
||||||
|
"actix-codec",
|
||||||
|
"actix-http",
|
||||||
|
"actix-web",
|
||||||
|
"bytestring",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1073,6 +1089,7 @@ version = "1.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-multipart",
|
"actix-multipart",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"actix-ws",
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1103,6 +1120,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
|
"tokio",
|
||||||
"ureq",
|
"ureq",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3209,6 +3227,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"futures-util",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ include_dir = "0.7.4"
|
|||||||
jzon = "0.12.5"
|
jzon = "0.12.5"
|
||||||
csv = "1.3"
|
csv = "1.3"
|
||||||
actix-multipart = "0.8"
|
actix-multipart = "0.8"
|
||||||
|
# Multi-live relay: WebSockets over actix-web 4 without the actor runtime
|
||||||
|
actix-ws = "0.4"
|
||||||
|
# tokio is already in the tree under actix-rt; only the mpsc channels are used
|
||||||
|
tokio = { version = "1", features = ["sync"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
DISABLE_IMPORTS: false # Will disable account imports
|
DISABLE_IMPORTS: false # Will disable account imports
|
||||||
DISABLE_EXPORTS: false # Will disable account exports
|
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_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)
|
||||||
|
#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
|
#PURGE: false # Purge dead user accounts on startup
|
||||||
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
||||||
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ args=(
|
|||||||
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
||||||
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
||||||
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
||||||
|
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
|
||||||
|
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
|
||||||
|
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
|
||||||
|
|
||||||
add_opt() {
|
add_opt() {
|
||||||
local value="$1" flag="$2"
|
local value="$1" flag="$2"
|
||||||
@@ -30,6 +33,15 @@ add_opt "${EN_ANDROID_ASSET_HASH:-}" --en-android-asset-hash
|
|||||||
add_opt "${EN_IOS_ASSET_HASH:-}" --en-ios-asset-hash
|
add_opt "${EN_IOS_ASSET_HASH:-}" --en-ios-asset-hash
|
||||||
add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
|
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.
|
# Asset / image paths.
|
||||||
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
|
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
|
||||||
add_opt "${MASTERDATA:-}" --masterdata
|
add_opt "${MASTERDATA:-}" --masterdata
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
pub mod gree;
|
pub mod gree;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
|
pub mod custom_card;
|
||||||
|
pub mod custom_3dmv;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod announcements;
|
||||||
|
pub mod arcade;
|
||||||
|
|||||||
267
src/database/announcements.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
474
src/database/arcade.rs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
use lazy_static::lazy_static;
|
||||||
|
use rusqlite::params;
|
||||||
|
use jzon::{array, object, JsonValue};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
use crate::router::global;
|
||||||
|
use crate::sql::SQLite;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref DATABASE: SQLite = SQLite::new("arcade.db", setup_tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cabinet owns exactly two accounts forever: the MACHINE account (the identity
|
||||||
|
// the attract loop and its demo lives run on) and one reusable GUEST account,
|
||||||
|
// rewritten from scratch at every credit. Neither is ever handed out twice and
|
||||||
|
// neither accumulates, so the arcade never leaves dead users behind.
|
||||||
|
//
|
||||||
|
// `cards` maps a physical card id to the account it plays as. `last_machine_id`
|
||||||
|
// / `last_session` are the "which cabinet is this card sitting at" record: a
|
||||||
|
// card account is not owned by any one machine, so /live/end attributes its play
|
||||||
|
// to the machine that most recently ran a session for that card. Keeping it as
|
||||||
|
// two columns on the row the session already writes is the whole design - no
|
||||||
|
// second table, no row to expire, and the attribution can never outlive the
|
||||||
|
// mapping it belongs to.
|
||||||
|
//
|
||||||
|
// `session_until` is the same row's other half and the one that costs money: the
|
||||||
|
// moment the credit that /api/arcade/session took stops buying LP-free lives.
|
||||||
|
// Before it, a card account plays as a cabinet does; after it, the same account
|
||||||
|
// is an ordinary phone account again. It is a stamp rather than a flag so that a
|
||||||
|
// cabinet that loses power mid-credit expires on its own with nothing to clean
|
||||||
|
// up, and so an operator can size the window with --arcade-session-ttl.
|
||||||
|
//
|
||||||
|
// `plays` is the bookkeeping ledger: one row per arcade song, cleared or not.
|
||||||
|
// `cleared` is 0 for a song whose life gauge emptied: the client plays it out and
|
||||||
|
// reports it at /live/retire rather than /live/end (there is no cleared flag on
|
||||||
|
// the end wire and live_end_ex records a clear unconditionally), so the retire is
|
||||||
|
// the only place the ledger can learn about a failed song. A credit's play is two
|
||||||
|
// songs whether they were passed or not, which is what makes the total the number
|
||||||
|
// an operator's bookkeeping counts.
|
||||||
|
fn setup_tables(conn: &rusqlite::Connection) {
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS machines (
|
||||||
|
machine_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
machine_user_id BIGINT NOT NULL,
|
||||||
|
guest_user_id BIGINT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
last_seen BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
card_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
created BIGINT NOT NULL,
|
||||||
|
last_machine_id TEXT NOT NULL DEFAULT '',
|
||||||
|
last_session BIGINT NOT NULL DEFAULT 0,
|
||||||
|
session_until BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS plays (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
machine_id TEXT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
live_id BIGINT NOT NULL,
|
||||||
|
level INT NOT NULL,
|
||||||
|
score BIGINT NOT NULL,
|
||||||
|
rank INT NOT NULL,
|
||||||
|
at BIGINT NOT NULL,
|
||||||
|
cleared INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS plays_machine ON plays (machine_id);
|
||||||
|
").unwrap();
|
||||||
|
// Upgrade databases written before the card session record existed. Existing
|
||||||
|
// mappings simply have no cabinet attached until their next session.
|
||||||
|
if conn.prepare("SELECT last_machine_id FROM cards LIMIT 1;").is_err() {
|
||||||
|
println!("Upgrading arcade card table");
|
||||||
|
conn.execute("ALTER TABLE cards ADD COLUMN last_machine_id TEXT NOT NULL DEFAULT '';", []).unwrap();
|
||||||
|
conn.execute("ALTER TABLE cards ADD COLUMN last_session BIGINT NOT NULL DEFAULT 0;", []).unwrap();
|
||||||
|
}
|
||||||
|
// Upgrade databases written before the LP-free window existed. 0 is "this
|
||||||
|
// card is not at a cabinet", so every existing mapping starts closed and
|
||||||
|
// opens at its next session - the safe direction.
|
||||||
|
if conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_err() {
|
||||||
|
println!("Upgrading arcade card table (session window)");
|
||||||
|
conn.execute("ALTER TABLE cards ADD COLUMN session_until BIGINT NOT NULL DEFAULT 0;", []).unwrap();
|
||||||
|
}
|
||||||
|
// Upgrade databases written before failed songs were recorded at all. Every
|
||||||
|
// row already in the ledger got there through /live/end, which is a clear.
|
||||||
|
if conn.prepare("SELECT cleared FROM plays LIMIT 1;").is_err() {
|
||||||
|
println!("Upgrading arcade play table (cleared)");
|
||||||
|
conn.execute("ALTER TABLE plays ADD COLUMN cleared INTEGER NOT NULL DEFAULT 1;", []).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16 hex characters, the shape the client stores in ArcadeSaveData.Machine and
|
||||||
|
// presents on every later call. It is the only thing that authenticates a
|
||||||
|
// cabinet, so it is drawn at full width rather than derived from anything
|
||||||
|
// guessable, and re-drawn on the (never observed) collision
|
||||||
|
pub fn generate_machine_id() -> String {
|
||||||
|
const CHARSET: &[u8] = b"0123456789abcdef";
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let id: String = (0..16)
|
||||||
|
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
|
||||||
|
.collect();
|
||||||
|
if machine_exists(&id) {
|
||||||
|
return generate_machine_id();
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn machine_exists(machine_id: &str) -> bool {
|
||||||
|
DATABASE.lock_and_select("SELECT machine_id FROM machines WHERE machine_id=?1", params!(machine_id)).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_machine(machine_id: &str, name: &str, machine_user_id: i64, guest_user_id: i64) {
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"INSERT INTO machines (machine_id, name, machine_user_id, guest_user_id, created, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
|
||||||
|
params!(machine_id, name, machine_user_id, guest_user_id, now)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn machine_row(conn: &rusqlite::Connection, machine_id: &str) -> Option<JsonValue> {
|
||||||
|
let mut stmt = conn.prepare("SELECT machine_id, name, machine_user_id, guest_user_id, created, last_seen FROM machines WHERE machine_id=?1").ok()?;
|
||||||
|
stmt.query_row(params!(machine_id), |row| {
|
||||||
|
Ok(object!{
|
||||||
|
machine_id: row.get::<usize, String>(0)?,
|
||||||
|
name: row.get::<usize, String>(1)?,
|
||||||
|
machine_user_id: row.get::<usize, i64>(2)?,
|
||||||
|
guest_user_id: row.get::<usize, i64>(3)?,
|
||||||
|
created: row.get::<usize, i64>(4)?,
|
||||||
|
last_seen: row.get::<usize, i64>(5)?
|
||||||
|
})
|
||||||
|
}).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_machine(machine_id: &str) -> Option<JsonValue> {
|
||||||
|
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
|
||||||
|
machine_row(&conn, machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cabinet is alive: every session (and every play it records) stamps it, and
|
||||||
|
// the purge sweeper measures a machine's age from here
|
||||||
|
pub fn touch_machine(machine_id: &str) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(global::timestamp() as i64, machine_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The machine a user account belongs to, when the account IS one of a cabinet's
|
||||||
|
// own two identities. A card-bound player account is nobody's, and answers None
|
||||||
|
pub fn machine_of_account(user_id: i64) -> Option<JsonValue> {
|
||||||
|
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
|
||||||
|
let mut stmt = conn.prepare("SELECT machine_id FROM machines WHERE machine_user_id=?1 OR guest_user_id=?1").ok()?;
|
||||||
|
let machine_id: String = stmt.query_row(params!(user_id), |row| row.get(0)).ok()?;
|
||||||
|
machine_row(&conn, &machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_machine(machine_id: &str) {
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM plays WHERE machine_id=?1", params!(machine_id));
|
||||||
|
DATABASE.lock_and_exec("UPDATE cards SET last_machine_id='', last_session=0, session_until=0 WHERE last_machine_id=?1", params!(machine_id));
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every machine with its play count, newest sighting first - the webui list
|
||||||
|
pub fn list_machines() -> JsonValue {
|
||||||
|
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mut stmt) = conn.prepare("
|
||||||
|
SELECT m.machine_id, m.name, m.machine_user_id, m.guest_user_id, m.created, m.last_seen,
|
||||||
|
(SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id),
|
||||||
|
(SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id AND p.cleared <> 0)
|
||||||
|
FROM machines m ORDER BY m.last_seen DESC
|
||||||
|
") else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mapped) = stmt.query_map(params!(), |row| {
|
||||||
|
Ok(object!{
|
||||||
|
machine_id: row.get::<usize, String>(0)?,
|
||||||
|
name: row.get::<usize, String>(1)?,
|
||||||
|
machine_user_id: row.get::<usize, i64>(2)?,
|
||||||
|
guest_user_id: row.get::<usize, i64>(3)?,
|
||||||
|
created: row.get::<usize, i64>(4)?,
|
||||||
|
last_seen: row.get::<usize, i64>(5)?,
|
||||||
|
play_count: row.get::<usize, i64>(6)?,
|
||||||
|
cleared_count: row.get::<usize, i64>(7)?
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let mut rv = array![];
|
||||||
|
for row in mapped.flatten() {
|
||||||
|
rv.push(row).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machines whose last sighting is older than `cutoff`. The two account ids come
|
||||||
|
// back with them: the sweeper deletes the accounts in the same pass
|
||||||
|
pub fn machines_last_seen_before(cutoff: i64) -> JsonValue {
|
||||||
|
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mut stmt) = conn.prepare("SELECT machine_id, machine_user_id, guest_user_id, last_seen FROM machines WHERE last_seen < ?1") else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mapped) = stmt.query_map(params!(cutoff), |row| {
|
||||||
|
Ok(object!{
|
||||||
|
machine_id: row.get::<usize, String>(0)?,
|
||||||
|
machine_user_id: row.get::<usize, i64>(1)?,
|
||||||
|
guest_user_id: row.get::<usize, i64>(2)?,
|
||||||
|
last_seen: row.get::<usize, i64>(3)?
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let mut rv = array![];
|
||||||
|
for row in mapped.flatten() {
|
||||||
|
rv.push(row).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn card_user(card_id: &str) -> Option<i64> {
|
||||||
|
DATABASE.lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point a card at an account. `created` survives a re-bind: the card is the same
|
||||||
|
// physical object, only the account behind it changed. The cabinet record is
|
||||||
|
// cleared, because the previous holder's sessions say nothing about this one -
|
||||||
|
// and so is the LP-free window, which was bought for the previous account
|
||||||
|
pub fn set_card(card_id: &str, user_id: i64) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"INSERT INTO cards (card_id, user_id, created, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, '', 0, 0)
|
||||||
|
ON CONFLICT(card_id) DO UPDATE SET user_id=?2, last_machine_id='', last_session=0, session_until=0",
|
||||||
|
params!(card_id, user_id, global::timestamp() as i64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cabinet this card is playing at right now and how long the credit it just
|
||||||
|
// paid buys LP-free lives for. Written by /api/arcade/session, and only there:
|
||||||
|
// the session is the one moment the server knows a credit was taken
|
||||||
|
pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4",
|
||||||
|
params!(machine_id, global::timestamp() as i64, until, card_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card of this account whose cabinet session is still open at `now`, as
|
||||||
|
// (card id, when the session started). None when no card of the account is at a
|
||||||
|
// cabinet - which is every phone account, and every card between credits.
|
||||||
|
pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> {
|
||||||
|
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT card_id, last_session FROM cards WHERE user_id=?1 AND session_until>?2 ORDER BY session_until DESC LIMIT 1"
|
||||||
|
).ok()?;
|
||||||
|
stmt.query_row(params!(user_id, now), |row| Ok((row.get::<usize, String>(0)?, row.get::<usize, i64>(1)?))).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live starting inside the window pushes its end back, so a credit whose songs
|
||||||
|
// run long is not cut off mid-play. Only ever forward, and never past the
|
||||||
|
// ceiling the caller computed from the session itself: extending without a
|
||||||
|
// ceiling would turn one credit into an endless supply of LP-free lives
|
||||||
|
pub fn extend_card_session(card_id: &str, until: i64) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"UPDATE cards SET session_until=?1 WHERE card_id=?2 AND session_until<?1",
|
||||||
|
params!(until, card_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the session tests need to age a card's window; production code only ever
|
||||||
|
// opens one at a session or pushes it forward through extend_card_session
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn backdate_card_session_for_test(card_id: &str, last_session: i64, session_until: i64) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"UPDATE cards SET last_session=?1, session_until=?2 WHERE card_id=?3",
|
||||||
|
params!(last_session, session_until, card_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The machine that most recently ran a session for this account through any of
|
||||||
|
// its cards. Empty when the account has no card, or has never been at a cabinet
|
||||||
|
pub fn last_machine_of_card_account(user_id: i64) -> Option<String> {
|
||||||
|
let machine_id: String = DATABASE.lock_and_select_type(
|
||||||
|
"SELECT last_machine_id FROM cards WHERE user_id=?1 AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1",
|
||||||
|
params!(user_id)
|
||||||
|
).ok()?;
|
||||||
|
if machine_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(machine_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_has_card(user_id: i64) -> bool {
|
||||||
|
DATABASE.lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_play(machine_id: &str, user_id: i64, live_id: i64, level: i64, score: i64, rank: i64, cleared: bool) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"INSERT INTO plays (machine_id, user_id, live_id, level, score, rank, at, cleared) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params!(machine_id, user_id, live_id, level, score, rank, global::timestamp() as i64, i64::from(cleared))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the sweeper's own test needs to move a cabinet's clock; production code
|
||||||
|
// only ever stamps last_seen forward through touch_machine
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn backdate_machine_for_test(machine_id: &str, last_seen: i64) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(last_seen, machine_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_count(machine_id: &str) -> i64 {
|
||||||
|
DATABASE.lock_and_select_type("SELECT COUNT(*) FROM plays WHERE machine_id=?1", params!(machine_id)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cabinet's ledger, newest first. Only the tests read it back today - the
|
||||||
|
// webui counts rows rather than listing them - but the ledger exists to be read
|
||||||
|
pub fn plays_of_machine(machine_id: &str) -> JsonValue {
|
||||||
|
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mut stmt) = conn.prepare(
|
||||||
|
"SELECT user_id, live_id, level, score, rank, at, cleared FROM plays WHERE machine_id=?1 ORDER BY id DESC"
|
||||||
|
) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mapped) = stmt.query_map(params!(machine_id), |row| {
|
||||||
|
Ok(object!{
|
||||||
|
user_id: row.get::<usize, i64>(0)?,
|
||||||
|
live_id: row.get::<usize, i64>(1)?,
|
||||||
|
level: row.get::<usize, i64>(2)?,
|
||||||
|
score: row.get::<usize, i64>(3)?,
|
||||||
|
rank: row.get::<usize, i64>(4)?,
|
||||||
|
at: row.get::<usize, i64>(5)?,
|
||||||
|
cleared: row.get::<usize, i64>(6)? != 0
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let mut rv = array![];
|
||||||
|
for row in mapped.flatten() {
|
||||||
|
rv.push(row).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// A cabinet owns its two accounts, is found from either of them, and its
|
||||||
|
// last_seen is what the purge sweeper measures
|
||||||
|
#[test]
|
||||||
|
fn a_machine_owns_its_two_accounts() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let id = generate_machine_id();
|
||||||
|
assert_eq!(id.len(), 16);
|
||||||
|
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
insert_machine(&id, "Cabinet 1", 111_111_111_111_111, 222_222_222_222_222);
|
||||||
|
let machine = get_machine(&id).unwrap();
|
||||||
|
assert_eq!(machine["name"].as_str(), Some("Cabinet 1"));
|
||||||
|
assert_eq!(machine["machine_user_id"].as_i64(), Some(111_111_111_111_111));
|
||||||
|
assert_eq!(machine["guest_user_id"].as_i64(), Some(222_222_222_222_222));
|
||||||
|
|
||||||
|
// Either of the two identities finds the cabinet; a stranger does not
|
||||||
|
assert_eq!(machine_of_account(111_111_111_111_111).unwrap()["machine_id"].as_str(), Some(id.as_str()));
|
||||||
|
assert_eq!(machine_of_account(222_222_222_222_222).unwrap()["machine_id"].as_str(), Some(id.as_str()));
|
||||||
|
assert!(machine_of_account(999_999_999_999_999).is_none());
|
||||||
|
|
||||||
|
// Aged out by the sweeper's cutoff, and only then
|
||||||
|
let seen = machine["last_seen"].as_i64().unwrap();
|
||||||
|
assert!(machines_last_seen_before(seen + 1).members().any(|m| m["machine_id"] == id.as_str()));
|
||||||
|
assert!(!machines_last_seen_before(seen).members().any(|m| m["machine_id"] == id.as_str()));
|
||||||
|
|
||||||
|
delete_machine(&id);
|
||||||
|
assert!(get_machine(&id).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card names an account; a re-bind repoints it and drops the previous
|
||||||
|
// holder's cabinet record; plays are attributed to the cabinet the card last
|
||||||
|
// sat at and die with the machine
|
||||||
|
#[test]
|
||||||
|
fn a_card_names_an_account_and_its_last_cabinet() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let machine = generate_machine_id();
|
||||||
|
insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444);
|
||||||
|
let card = "0123456789012345";
|
||||||
|
|
||||||
|
assert!(card_user(card).is_none());
|
||||||
|
set_card(card, 555_555_555_555_555);
|
||||||
|
assert_eq!(card_user(card), Some(555_555_555_555_555));
|
||||||
|
assert!(account_has_card(555_555_555_555_555));
|
||||||
|
// Never at a cabinet yet
|
||||||
|
assert!(last_machine_of_card_account(555_555_555_555_555).is_none());
|
||||||
|
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
open_card_session(card, &machine, now + 60);
|
||||||
|
assert_eq!(last_machine_of_card_account(555_555_555_555_555).as_deref(), Some(machine.as_str()));
|
||||||
|
|
||||||
|
// A re-bind keeps the card, moves the account and forgets the cabinet -
|
||||||
|
// and the window the previous account's credit paid for
|
||||||
|
set_card(card, 666_666_666_666_666);
|
||||||
|
assert_eq!(card_user(card), Some(666_666_666_666_666));
|
||||||
|
assert!(!account_has_card(555_555_555_555_555));
|
||||||
|
assert!(last_machine_of_card_account(666_666_666_666_666).is_none());
|
||||||
|
assert!(live_card_session(666_666_666_666_666, now).is_none(), "a re-bind carried the previous account's credit over");
|
||||||
|
|
||||||
|
assert_eq!(play_count(&machine), 0);
|
||||||
|
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true);
|
||||||
|
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 123_456, 2, false);
|
||||||
|
assert_eq!(play_count(&machine), 2);
|
||||||
|
// A failed song is a song: it is in the ledger and in the total, and the
|
||||||
|
// cleared count is what separates the two
|
||||||
|
assert!(list_machines().members().any(|m|
|
||||||
|
m["machine_id"] == machine.as_str() && m["play_count"] == 2 && m["cleared_count"] == 1
|
||||||
|
));
|
||||||
|
let ledger = plays_of_machine(&machine);
|
||||||
|
assert_eq!(ledger.len(), 2);
|
||||||
|
assert_eq!(ledger[0]["cleared"].as_bool(), Some(false));
|
||||||
|
assert_eq!(ledger[0]["score"].as_i64(), Some(123_456));
|
||||||
|
assert_eq!(ledger[1]["cleared"].as_bool(), Some(true));
|
||||||
|
|
||||||
|
// Removing the cabinet takes its ledger with it and unlinks the card
|
||||||
|
open_card_session(card, &machine, now + 60);
|
||||||
|
delete_machine(&machine);
|
||||||
|
assert_eq!(play_count(&machine), 0);
|
||||||
|
assert!(last_machine_of_card_account(666_666_666_666_666).is_none());
|
||||||
|
assert!(live_card_session(666_666_666_666_666, now).is_none(), "a retired cabinet left a credit open");
|
||||||
|
assert_eq!(card_user(card), Some(666_666_666_666_666));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credit a card paid for is a window on its own row: open until it is
|
||||||
|
// not, pushed forward but never backward, and never shared with another card
|
||||||
|
// of another account
|
||||||
|
#[test]
|
||||||
|
fn a_credit_opens_a_window_that_closes_on_its_own() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let machine = generate_machine_id();
|
||||||
|
insert_machine(&machine, "Cabinet 3", 777_777_777_777_777, 888_888_888_888_888);
|
||||||
|
let card = "1212121212121212";
|
||||||
|
let user = 121_212_121_212_121;
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
|
||||||
|
// A mapping with no session behind it is not a cabinet session
|
||||||
|
set_card(card, user);
|
||||||
|
assert!(live_card_session(user, now).is_none());
|
||||||
|
|
||||||
|
open_card_session(card, &machine, now + 600);
|
||||||
|
let (open_card, opened) = live_card_session(user, now).expect("the credit did not open a window");
|
||||||
|
assert_eq!(open_card, card);
|
||||||
|
assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now);
|
||||||
|
|
||||||
|
// The window closes by itself, with nothing to sweep
|
||||||
|
assert!(live_card_session(user, now + 599).is_some());
|
||||||
|
assert!(live_card_session(user, now + 600).is_none(), "the window outlived its own expiry");
|
||||||
|
assert!(live_card_session(user, now + 601).is_none());
|
||||||
|
|
||||||
|
// A live inside it pushes it forward, and only forward
|
||||||
|
extend_card_session(card, now + 1200);
|
||||||
|
assert!(live_card_session(user, now + 900).is_some());
|
||||||
|
extend_card_session(card, now + 300);
|
||||||
|
assert!(live_card_session(user, now + 900).is_some(), "an extension moved the window backwards");
|
||||||
|
|
||||||
|
// Another account's card is untouched by any of it
|
||||||
|
let other_card = "3434343434343434";
|
||||||
|
let other_user = 343_434_343_434_343;
|
||||||
|
set_card(other_card, other_user);
|
||||||
|
assert!(live_card_session(other_user, now).is_none());
|
||||||
|
|
||||||
|
delete_machine(&machine);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/database/custom_3dmv.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"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)
|
||||||
|
);
|
||||||
|
DATABASE.lock_and_exec("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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
pub fn blob_in_use(md5: &str) -> bool {
|
||||||
|
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5))).unwrap_or(array![]);
|
||||||
|
for blob in rows.members() {
|
||||||
|
if let Ok(mv) = jzon::parse(&blob.to_string()) {
|
||||||
|
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
blob_in_use(md5)
|
||||||
|
}
|
||||||
555
src/database/custom_card.rs
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
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_cards.db", setup_tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// master_card_id == illust_prefix * 10000 + seq, and illust_id is derived from
|
||||||
|
// the same pair as {prefix:05}_{seq:04}_{00|01}. Official cards are 8-digit
|
||||||
|
// (prefix 1001-4014), the baked SIF1 import owns prefixes 10000-14999 (rows in
|
||||||
|
// client masterdata), so runtime uploads start at prefix 15000. seq 0 never
|
||||||
|
// exists - official illust ids start at 0001 - so an id landing on a prefix
|
||||||
|
// boundary is skipped, not issued
|
||||||
|
pub const FIRST_ILLUST_PREFIX: i64 = 15000;
|
||||||
|
pub const FIRST_CARD_ID: i64 = FIRST_ILLUST_PREFIX * 10000 + 1;
|
||||||
|
pub const LAST_CARD_ID: i64 = 999_999_999;
|
||||||
|
|
||||||
|
// Official characters are 1001-4014, the SIF1 import took 5001-5172 and
|
||||||
|
// 6001-6009 - client masterdata tops out at 6009, so runtime characters start
|
||||||
|
// at 7001. The ceiling keeps the id 5-digit (sign_{0:D5} asset naming) and
|
||||||
|
// below the 99999 sentinel SDCharacter substitutes for OTHER-category
|
||||||
|
// characters
|
||||||
|
pub const FIRST_CHARACTER_ID: i64 = 7001;
|
||||||
|
pub const LAST_CHARACTER_ID: i64 = 99_998;
|
||||||
|
|
||||||
|
// Cards and characters are one JSON blob each, in the exact shape
|
||||||
|
// /api/custom_card/list serves - except `published`/`obtainable`/`rarity`,
|
||||||
|
// which live in their own columns (the draw pool and the catalog filter query
|
||||||
|
// them) and are injected into the served object where the wire wants them
|
||||||
|
fn setup_tables(conn: &rusqlite::Connection) {
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS 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
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS characters (
|
||||||
|
master_character_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
owner_id BIGINT NOT NULL,
|
||||||
|
character TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS revision (
|
||||||
|
id INT NOT NULL PRIMARY KEY,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
last_card_id BIGINT NOT NULL,
|
||||||
|
last_character_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 create/update/delete/publish/obtainable 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_card_id, last_character_id) VALUES (1, 1, 0, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids are never reused after a delete: a client that cached a dead id can't
|
||||||
|
// confuse it with a later upload, and a player's stored card row for a deleted
|
||||||
|
// card can't silently become a different card. last_card_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_card_id() -> i64 {
|
||||||
|
let issued = DATABASE.lock_and_select("SELECT last_card_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||||
|
let max = DATABASE.lock_and_select("SELECT MAX(master_card_id) FROM cards", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||||
|
let mut rv = std::cmp::max(std::cmp::max(issued, max), FIRST_CARD_ID - 1) + 1;
|
||||||
|
// seq 0 doesn't exist in the illust naming scheme
|
||||||
|
if rv % 10000 == 0 {
|
||||||
|
rv += 1;
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_character_id() -> i64 {
|
||||||
|
let issued = DATABASE.lock_and_select("SELECT last_character_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||||
|
let max = DATABASE.lock_and_select("SELECT MAX(master_character_id) FROM characters", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog blob only. The owner and the published/obtainable flags live in
|
||||||
|
// their own columns and are untouched here; rarity tracks the blob
|
||||||
|
pub fn update_card(master_card_id: i64, card: &JsonValue) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE cards SET card=?1, rarity=?2 WHERE master_card_id=?3", params!(jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), master_card_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_character(master_character_id: i64, character: &JsonValue) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE characters SET character=?1 WHERE master_character_id=?2", params!(jzon::stringify(character.clone()), master_character_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_card(master_card_id: i64) {
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM cards WHERE master_card_id=?1", params!(master_card_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_character(master_character_id: i64) {
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM characters WHERE master_character_id=?1", params!(master_character_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored blob with the column-backed wire field injected
|
||||||
|
fn card_with_flags(mut card: JsonValue) -> JsonValue {
|
||||||
|
let id = card["master_card_id"].as_i64().unwrap_or(0);
|
||||||
|
card["obtainable"] = is_obtainable(id).into();
|
||||||
|
card
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_card(master_card_id: i64) -> Option<JsonValue> {
|
||||||
|
let card = DATABASE.lock_and_select("SELECT card FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?;
|
||||||
|
Some(card_with_flags(jzon::parse(&card).ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_character(master_character_id: i64) -> Option<JsonValue> {
|
||||||
|
let character = DATABASE.lock_and_select("SELECT character FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?;
|
||||||
|
jzon::parse(&character).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_card_owner(master_card_id: i64) -> Option<i64> {
|
||||||
|
DATABASE.lock_and_select("SELECT owner_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_character_owner(master_character_id: i64) -> Option<i64> {
|
||||||
|
DATABASE.lock_and_select("SELECT owner_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The character a runtime card belongs to, straight out of its own column.
|
||||||
|
// guest::proxy_card_id resolves an unviewable card through this, so it stays a
|
||||||
|
// plain lookup and never a decode of the blob
|
||||||
|
pub fn character_of(master_card_id: i64) -> Option<i64> {
|
||||||
|
DATABASE.lock_and_select("SELECT master_character_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_published(master_card_id: i64) -> bool {
|
||||||
|
DATABASE.lock_and_select("SELECT published FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_published(master_card_id: i64, published: bool) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE cards SET published=?1 WHERE master_card_id=?2", params!(published as i64, master_card_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_obtainable(master_card_id: i64) -> bool {
|
||||||
|
DATABASE.lock_and_select("SELECT obtainable FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_obtainable(master_card_id: i64, obtainable: bool) {
|
||||||
|
DATABASE.lock_and_exec("UPDATE cards SET obtainable=?1 WHERE master_card_id=?2", params!(obtainable as i64, master_card_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many cards still point at this character. A referenced character can't
|
||||||
|
// be deleted
|
||||||
|
pub fn cards_using_character(master_character_id: i64) -> i64 {
|
||||||
|
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE master_character_id=?1", params!(master_character_id)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom character is publicly visible when a published card references it -
|
||||||
|
// that's also what lets another uploader build a card on it
|
||||||
|
pub fn character_publicly_visible(master_character_id: i64) -> bool {
|
||||||
|
DATABASE.lock_and_select_type::<i64>(
|
||||||
|
"SELECT COUNT(*) FROM cards WHERE master_character_id=?1 AND published=1",
|
||||||
|
params!(master_character_id)
|
||||||
|
).unwrap_or(0) > 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 card catalog this user is served: every published card, their own
|
||||||
|
// drafts, and any card their game account already owns (`owned` - so an
|
||||||
|
// unpublish never leaves a player holding an id their client can't resolve)
|
||||||
|
pub fn get_cards_for_user(user_id: i64, owned: &[i64]) -> JsonValue {
|
||||||
|
let rows = parse_blobs(DATABASE.lock_and_select_all(
|
||||||
|
"SELECT card FROM cards WHERE published=1 OR owner_id=?1 ORDER BY master_card_id",
|
||||||
|
params!(user_id)
|
||||||
|
).unwrap_or(array![]));
|
||||||
|
let mut rv = array![];
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
for card in rows.members() {
|
||||||
|
ids.push(card["master_card_id"].as_i64().unwrap_or(0));
|
||||||
|
rv.push(card_with_flags(card.clone())).unwrap();
|
||||||
|
}
|
||||||
|
for id in owned {
|
||||||
|
if ids.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(card) = get_card(*id) {
|
||||||
|
rv.push(card).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// The character catalog rides along with the cards: a custom character is
|
||||||
|
// included exactly when a served card references it, or the requester owns it
|
||||||
|
// (so a draft character never leaks, and the catalog is referentially closed -
|
||||||
|
// a served card can never name a master_character_id the same response failed
|
||||||
|
// to deliver)
|
||||||
|
pub fn get_characters_for_cards(user_id: i64, cards: &JsonValue) -> JsonValue {
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
for card in cards.members() {
|
||||||
|
let id = card["master_character_id"].as_i64().unwrap_or(0);
|
||||||
|
if (FIRST_CHARACTER_ID..=LAST_CHARACTER_ID).contains(&id) && !ids.contains(&id) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let own = DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(user_id)).unwrap_or(array![]);
|
||||||
|
for id in own.members() {
|
||||||
|
let id = id.as_i64().unwrap_or(0);
|
||||||
|
if !ids.contains(&id) {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.sort();
|
||||||
|
let mut rv = array![];
|
||||||
|
for id in ids {
|
||||||
|
if let Some(character) = get_character(id) {
|
||||||
|
rv.push(character).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// The custom characters this user may build a card on (mirrors
|
||||||
|
// validate_character_ref): their own, plus any that is publicly visible
|
||||||
|
// through a published card. Feeds the webui's character picker
|
||||||
|
pub fn get_selectable_characters(user_id: i64) -> JsonValue {
|
||||||
|
parse_blobs(DATABASE.lock_and_select_all("
|
||||||
|
SELECT character FROM characters
|
||||||
|
WHERE owner_id=?1 OR master_character_id IN (SELECT master_character_id FROM cards WHERE published=1)
|
||||||
|
ORDER BY master_character_id", params!(user_id)).unwrap_or(array![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card blobs plus the flag columns, for the webui manage view
|
||||||
|
pub fn get_cards_by_owner(owner_id: i64) -> JsonValue {
|
||||||
|
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id)).unwrap_or(array![]));
|
||||||
|
let mut rv = array![];
|
||||||
|
for card in rows.members() {
|
||||||
|
let mut card = card_with_flags(card.clone());
|
||||||
|
card["published"] = is_published(card["master_card_id"].as_i64().unwrap_or(0)).into();
|
||||||
|
rv.push(card).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_characters_by_owner(owner_id: i64) -> JsonValue {
|
||||||
|
parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id)).unwrap_or(array![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The webui card browser: every published card, plus the owner id so the page
|
||||||
|
// can label the uploader
|
||||||
|
pub fn get_browse_cards() -> JsonValue {
|
||||||
|
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE published=1 ORDER BY master_card_id", params!()).unwrap_or(array![]));
|
||||||
|
let mut rv = array![];
|
||||||
|
for card in rows.members() {
|
||||||
|
let mut card = card_with_flags(card.clone());
|
||||||
|
card["owner_id"] = get_card_owner(card["master_card_id"].as_i64().unwrap_or(0)).unwrap_or(0).into();
|
||||||
|
rv.push(card).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which of these candidate ids no longer exist. Only the runtime band is ever
|
||||||
|
// 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 {
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
for id in candidates.members() {
|
||||||
|
let Some(id) = id.as_i64() else { continue; };
|
||||||
|
if (FIRST_CARD_ID..=LAST_CARD_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 master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]);
|
||||||
|
let mut rv = array![];
|
||||||
|
for id in ids {
|
||||||
|
if !alive.contains(id) {
|
||||||
|
rv.push(id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// The published + obtainable pool the custom gacha banner draws from, per
|
||||||
|
// rarity
|
||||||
|
pub fn obtainable_card_ids(rarity: i64) -> Vec<i64> {
|
||||||
|
let rows = DATABASE.lock_and_select_all(
|
||||||
|
"SELECT master_card_id FROM cards WHERE published=1 AND obtainable=1 AND rarity=?1 ORDER BY master_card_id",
|
||||||
|
params!(rarity)
|
||||||
|
).unwrap_or(array![]);
|
||||||
|
rows.members().filter_map(|id| id.as_i64()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a content-addressed art md5 to the file under custom_cards/ that
|
||||||
|
// currently holds those bytes: card art lives at {card_id}/{kind}_{variant}.png,
|
||||||
|
// character art at characters/{character_id}/{kind}.png. Art is stored per
|
||||||
|
// entity (not a shared md5 store) and the catalog md5 always tracks the
|
||||||
|
// on-disk bytes - so this is the index the /custom_card/data/{md5} route
|
||||||
|
// serves from, and it self-heals: a replaced file gets a new md5 and the old
|
||||||
|
// one simply stops resolving
|
||||||
|
// Resolve a voiceline md5 to its ogg under custom_cards/. Voicelines live in
|
||||||
|
// the character blob's `voice` array and on disk under the character's own
|
||||||
|
// voice/ subdirectory, so they share the art routes' self-healing property
|
||||||
|
pub fn find_voice_by_md5(md5: &str) -> Option<String> {
|
||||||
|
let blob = DATABASE.lock_and_select("SELECT character FROM characters WHERE character LIKE ?1", params!(format!("%{}%", md5))).ok()?;
|
||||||
|
let character = jzon::parse(&blob).ok()?;
|
||||||
|
let id = character["master_character_id"].as_i64()?;
|
||||||
|
for line in character["voice"].members() {
|
||||||
|
if line["md5"].as_str() == Some(md5) {
|
||||||
|
return Some(format!("characters/{}/voice/{}.ogg", id, md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_asset_by_md5(md5: &str) -> Option<String> {
|
||||||
|
let like = format!("%{}%", md5);
|
||||||
|
if let Ok(blob) = DATABASE.lock_and_select("SELECT card FROM cards WHERE card LIKE ?1", params!(like.clone())) {
|
||||||
|
if let Ok(card) = jzon::parse(&blob) {
|
||||||
|
let master_card_id = card["master_card_id"].as_i64()?;
|
||||||
|
for art in card["art"].members() {
|
||||||
|
if art["md5"].as_str() == Some(md5) {
|
||||||
|
return Some(format!("{}/{}_{}.png", master_card_id, art["kind"], art["variant"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let blob = DATABASE.lock_and_select("SELECT character FROM characters WHERE character LIKE ?1", params!(like)).ok()?;
|
||||||
|
let character = jzon::parse(&blob).ok()?;
|
||||||
|
let id = character["master_character_id"].as_i64()?;
|
||||||
|
for art in character["art"].members() {
|
||||||
|
if art["md5"].as_str() == Some(md5) {
|
||||||
|
return Some(format!("characters/{}/{}.png", id, art["kind"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use jzon::object;
|
||||||
|
|
||||||
|
fn card_blob(id: i64, rarity: i64) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
"master_card_id": id,
|
||||||
|
"master_character_id": 1001,
|
||||||
|
"rarity": rarity,
|
||||||
|
"art": [{ "kind": "c", "variant": "00", "md5": format!("{:032x}", id), "size": 1 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wipe(owner: i64) {
|
||||||
|
for card in get_cards_by_owner(owner).members() {
|
||||||
|
delete_card(card["master_card_id"].as_i64().unwrap());
|
||||||
|
}
|
||||||
|
for character in get_characters_by_owner(owner).members() {
|
||||||
|
delete_character(character["master_character_id"].as_i64().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids are sequential from FIRST_CARD_ID, never reused after a delete, and
|
||||||
|
// never land on a seq-0 prefix boundary
|
||||||
|
#[test]
|
||||||
|
fn ids_are_sequential_and_never_reused() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(3001);
|
||||||
|
|
||||||
|
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);
|
||||||
|
let second = next_card_id();
|
||||||
|
assert_eq!(second, first + 1);
|
||||||
|
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false);
|
||||||
|
delete_card(second);
|
||||||
|
assert_eq!(get_card(second), None);
|
||||||
|
// The high-water mark survives the delete, so the id is retired
|
||||||
|
assert_eq!(next_card_id(), second + 1);
|
||||||
|
|
||||||
|
let character = next_character_id();
|
||||||
|
assert!(character >= FIRST_CHARACTER_ID);
|
||||||
|
insert_character(character, 3001, &object!{ "master_character_id": character });
|
||||||
|
assert_eq!(next_character_id(), character + 1);
|
||||||
|
delete_character(character);
|
||||||
|
assert_eq!(next_character_id(), character + 1);
|
||||||
|
|
||||||
|
wipe(3001);
|
||||||
|
assert!(next_card_id() > second);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published cards go to everyone, drafts only to their owner, a game
|
||||||
|
// account that owns a card keeps seeing it even unpublished, and a
|
||||||
|
// character rides along with any card the viewer is served
|
||||||
|
#[test]
|
||||||
|
fn catalog_filters_per_user() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(3003);
|
||||||
|
wipe(3004);
|
||||||
|
|
||||||
|
let character = next_character_id();
|
||||||
|
insert_character(character, 3003, &object!{ "master_character_id": character });
|
||||||
|
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);
|
||||||
|
let draft = next_card_id();
|
||||||
|
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false);
|
||||||
|
|
||||||
|
let owner_view = get_cards_for_user(3003, &[]);
|
||||||
|
assert_eq!(owner_view.len(), 2);
|
||||||
|
let other_view = get_cards_for_user(3004, &[]);
|
||||||
|
assert_eq!(other_view.len(), 1);
|
||||||
|
assert_eq!(other_view[0]["master_card_id"].as_i64(), Some(published));
|
||||||
|
// The wire `obtainable` field comes from the column
|
||||||
|
assert_eq!(other_view[0]["obtainable"].as_bool(), Some(true));
|
||||||
|
|
||||||
|
// A player who owns the draft in game still resolves it
|
||||||
|
let holder_view = get_cards_for_user(3004, &[draft]);
|
||||||
|
assert_eq!(holder_view.len(), 2);
|
||||||
|
|
||||||
|
// Unpublishing removes it from strangers, not from holders
|
||||||
|
set_published(published, false);
|
||||||
|
assert!(get_cards_for_user(3004, &[]).is_empty());
|
||||||
|
assert_eq!(get_cards_for_user(3004, &[published]).len(), 1);
|
||||||
|
set_published(published, true);
|
||||||
|
|
||||||
|
// Characters follow the served cards; a stranger with no visible card
|
||||||
|
// on the character doesn't get it
|
||||||
|
let characters = get_characters_for_cards(3004, &get_cards_for_user(3004, &[]));
|
||||||
|
assert_eq!(characters.len(), 1);
|
||||||
|
assert_eq!(characters[0]["master_character_id"].as_i64(), Some(character));
|
||||||
|
set_published(published, false);
|
||||||
|
assert!(get_characters_for_cards(3004, &get_cards_for_user(3004, &[])).is_empty());
|
||||||
|
// The owner always sees their own character
|
||||||
|
assert_eq!(get_characters_for_cards(3003, &get_cards_for_user(3003, &[])).len(), 1);
|
||||||
|
set_published(published, true);
|
||||||
|
|
||||||
|
assert_eq!(character_of(published), Some(character));
|
||||||
|
assert_eq!(cards_using_character(character), 2);
|
||||||
|
assert!(character_publicly_visible(character));
|
||||||
|
assert_eq!(card_count_for_owner(3003), 2);
|
||||||
|
|
||||||
|
wipe(3003);
|
||||||
|
wipe(3004);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The draw pool is exactly the published + obtainable cards of the rarity
|
||||||
|
#[test]
|
||||||
|
fn obtainable_pool_by_rarity() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(3005);
|
||||||
|
|
||||||
|
let r1 = next_card_id();
|
||||||
|
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true);
|
||||||
|
let r3 = next_card_id();
|
||||||
|
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true);
|
||||||
|
let unpublished = next_card_id();
|
||||||
|
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true);
|
||||||
|
let unobtainable = next_card_id();
|
||||||
|
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false);
|
||||||
|
|
||||||
|
assert_eq!(obtainable_card_ids(1), vec![r1]);
|
||||||
|
assert_eq!(obtainable_card_ids(3), vec![r3]);
|
||||||
|
assert!(obtainable_card_ids(2).is_empty());
|
||||||
|
set_obtainable(unobtainable, true);
|
||||||
|
assert_eq!(obtainable_card_ids(1), vec![r1, unobtainable]);
|
||||||
|
|
||||||
|
wipe(3005);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn md5_resolves_to_the_file_holding_the_bytes() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(3007);
|
||||||
|
|
||||||
|
let id = next_card_id();
|
||||||
|
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false);
|
||||||
|
assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id)));
|
||||||
|
assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None);
|
||||||
|
|
||||||
|
let character = next_character_id();
|
||||||
|
insert_character(character, 3007, &object!{
|
||||||
|
"master_character_id": character,
|
||||||
|
"art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }]
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
find_asset_by_md5("aabbccddeeff00112233445566778899"),
|
||||||
|
Some(format!("characters/{}/icon.png", character))
|
||||||
|
);
|
||||||
|
|
||||||
|
wipe(3007);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted ids come back from dead_card_ids; unpublished, imported and
|
||||||
|
// official ids never do
|
||||||
|
#[test]
|
||||||
|
fn dead_ids_are_deleted_ids_only() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(3008);
|
||||||
|
|
||||||
|
let alive = next_card_id();
|
||||||
|
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false);
|
||||||
|
let dead = next_card_id();
|
||||||
|
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false);
|
||||||
|
delete_card(dead);
|
||||||
|
|
||||||
|
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]);
|
||||||
|
assert_eq!(dead_ids.len(), 1);
|
||||||
|
assert_eq!(dead_ids[0].as_i64(), Some(dead));
|
||||||
|
|
||||||
|
wipe(3008);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,6 +239,32 @@ pub fn export_allowed(music_id: i64, viewer: Option<i64>) -> Result<(), &'static
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The display title for a PUBLIC custom song, for the clear-rate page.
|
||||||
|
// Private/shared songs return None - their names (and existence) must not
|
||||||
|
// leak beyond the visibility rules, so this never falls back past the
|
||||||
|
// explicit visibility check (an absent row is None, not "public")
|
||||||
|
pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||||
|
if !(FIRST_MUSIC_ID..=LAST_MUSIC_ID).contains(&music_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let visibility = DATABASE.lock_and_select("SELECT visibility FROM songs WHERE music_id=?1", params!(music_id)).ok()?;
|
||||||
|
if visibility != "public" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let song = get_song(music_id)?;
|
||||||
|
let name = song["name"].as_str().unwrap_or("").to_string();
|
||||||
|
let name_en = song["name_en"].as_str().unwrap_or("").to_string();
|
||||||
|
Some(if english && !name_en.is_empty() { name_en } else { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
|
||||||
DATABASE.lock_and_select_all("
|
DATABASE.lock_and_select_all("
|
||||||
SELECT music_id FROM songs
|
SELECT music_id FROM songs
|
||||||
@@ -285,6 +311,14 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
|
|||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every stored catalog blob, unparsed and unfiltered by visibility. Only the
|
||||||
|
// startup audio sweep needs the whole table, and it needs to tell "no songs"
|
||||||
|
// apart from "could not read the songs" - a read failure must never look like
|
||||||
|
// an empty catalog, so this returns None rather than an empty array on error
|
||||||
|
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
|
// Audio files are content-addressed and may be shared between songs
|
||||||
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool {
|
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()
|
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()
|
||||||
|
|||||||
332
src/database/permissions.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
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("permissions.db", setup_tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ALL: &str = "*";
|
||||||
|
|
||||||
|
pub const CARD: &str = "card";
|
||||||
|
pub const CARD_UPLOAD: &str = "card.upload";
|
||||||
|
pub const CARD_PUBLISH: &str = "card.publish";
|
||||||
|
pub const CARD_EDIT: &str = "card.edit";
|
||||||
|
|
||||||
|
pub const PERMISSION: &str = "permission";
|
||||||
|
pub const PERMISSION_GRANT: &str = "permission.grant";
|
||||||
|
pub const PERMISSION_REVOKE: &str = "permission.revoke";
|
||||||
|
|
||||||
|
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,
|
||||||
|
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
|
||||||
|
MV, MV_EDIT
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
fn setup_tables(conn: &rusqlite::Connection) {
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE IF NOT EXISTS grants (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
granted_by BIGINT NOT NULL,
|
||||||
|
granted_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, scope)
|
||||||
|
);
|
||||||
|
").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_owner(user_id: i64) -> bool {
|
||||||
|
user_id > 0 && crate::runtime::get_owners().contains(&user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_permission(scope: &str) -> Vec<String> {
|
||||||
|
if scope == ALL {
|
||||||
|
return vec![String::from(ALL)];
|
||||||
|
}
|
||||||
|
let mut rv = vec![String::from(ALL)];
|
||||||
|
let mut prefix = String::new();
|
||||||
|
for part in scope.split('.') {
|
||||||
|
if !prefix.is_empty() {
|
||||||
|
prefix.push('.');
|
||||||
|
}
|
||||||
|
prefix.push_str(part);
|
||||||
|
rv.push(prefix.clone());
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(user_id: i64, scope: &str, granted_by: i64) {
|
||||||
|
DATABASE.lock_and_exec(
|
||||||
|
"INSERT OR IGNORE INTO grants (user_id, scope, granted_by, granted_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params!(user_id, scope, granted_by, global::timestamp() as i64)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no row holds nothing at all
|
||||||
|
pub fn has(user_id: i64, scope: &str) -> bool {
|
||||||
|
if user_id <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if is_owner(user_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let held = get_permissions(user_id);
|
||||||
|
has_permission(scope).iter().any(|candidate| held.contains(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_permissions(user_id: i64) -> JsonValue {
|
||||||
|
if user_id <= 0 {
|
||||||
|
return array![];
|
||||||
|
}
|
||||||
|
let mut scopes: Vec<String> = Vec::new();
|
||||||
|
if is_owner(user_id) {
|
||||||
|
scopes.push(String::from(ALL));
|
||||||
|
}
|
||||||
|
for scope in get_permissions(user_id) {
|
||||||
|
if !scopes.contains(&scope) {
|
||||||
|
scopes.push(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut rv = array![];
|
||||||
|
for scope in scopes {
|
||||||
|
rv.push(scope).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grants() -> JsonValue {
|
||||||
|
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
|
||||||
|
let Ok(mut stmt) = conn.prepare("SELECT user_id, scope, granted_by, granted_at FROM grants ORDER BY user_id, scope") else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let Ok(mapped) = stmt.query_map(params!(), |row| {
|
||||||
|
Ok(object!{
|
||||||
|
user_id: row.get::<usize, i64>(0)?,
|
||||||
|
scope: row.get::<usize, String>(1)?,
|
||||||
|
granted_by: row.get::<usize, i64>(2)?,
|
||||||
|
granted_at: row.get::<usize, i64>(3)?
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return array![];
|
||||||
|
};
|
||||||
|
let mut rv = array![];
|
||||||
|
for row in mapped.flatten() {
|
||||||
|
rv.push(row).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
|
||||||
|
if user_id <= 0 {
|
||||||
|
return Err(String::from("Invalid user id"));
|
||||||
|
}
|
||||||
|
if !SCOPES.contains(&scope) {
|
||||||
|
return Err(format!("Unknown scope '{}'", scope));
|
||||||
|
}
|
||||||
|
if !has(granted_by, PERMISSION_GRANT) {
|
||||||
|
return Err(String::from("You do not have permission to grant scopes"));
|
||||||
|
}
|
||||||
|
if !has(granted_by, scope) {
|
||||||
|
return Err(format!("You cannot grant '{}' because you do not hold it yourself", scope));
|
||||||
|
}
|
||||||
|
insert(user_id, scope, granted_by);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> {
|
||||||
|
if user_id <= 0 {
|
||||||
|
return Err(String::from("Invalid user id"));
|
||||||
|
}
|
||||||
|
if !SCOPES.contains(&scope) {
|
||||||
|
return Err(format!("Unknown scope '{}'", scope));
|
||||||
|
}
|
||||||
|
if !has(revoked_by, PERMISSION_REVOKE) {
|
||||||
|
return Err(String::from("You do not have permission to revoke scopes"));
|
||||||
|
}
|
||||||
|
if !has(revoked_by, scope) {
|
||||||
|
return Err(format!("You cannot revoke '{}' because you do not hold it yourself", scope));
|
||||||
|
}
|
||||||
|
if is_owner(user_id) {
|
||||||
|
return Err(String::from("A server owner's scopes cannot be revoked"));
|
||||||
|
}
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1 AND scope=?2", params!(user_id, scope));
|
||||||
|
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::*;
|
||||||
|
|
||||||
|
// Every test holds the shared test data path lock, so uids are hand-picked
|
||||||
|
// to not collide between tests rather than cleaned up between them
|
||||||
|
fn wipe(user_id: i64) {
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1", params!(user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtree_implies_leaf_but_not_the_reverse() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(101);
|
||||||
|
assert!(!has(101, CARD_UPLOAD));
|
||||||
|
insert(101, CARD, 0);
|
||||||
|
assert!(has(101, CARD_UPLOAD));
|
||||||
|
assert!(has(101, CARD_EDIT));
|
||||||
|
assert!(has(101, CARD));
|
||||||
|
assert!(!has(101, PERMISSION_GRANT));
|
||||||
|
assert!(!has(101, ALL));
|
||||||
|
wipe(101);
|
||||||
|
|
||||||
|
insert(101, CARD_UPLOAD, 0);
|
||||||
|
assert!(has(101, CARD_UPLOAD));
|
||||||
|
assert!(!has(101, CARD));
|
||||||
|
assert!(!has(101, CARD_PUBLISH));
|
||||||
|
wipe(101);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn star_implies_everything() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(103);
|
||||||
|
insert(103, ALL, 0);
|
||||||
|
for scope in SCOPES {
|
||||||
|
assert!(has(103, scope), "scope {}", scope);
|
||||||
|
}
|
||||||
|
wipe(103);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_segment_is_not_a_prefix() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(104);
|
||||||
|
insert(104, "car", 0);
|
||||||
|
assert!(!has(104, CARD_UPLOAD));
|
||||||
|
assert!(!has(104, CARD));
|
||||||
|
insert(104, "card.uplo", 0);
|
||||||
|
assert!(!has(104, CARD_UPLOAD));
|
||||||
|
wipe(104);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_user_holds_nothing() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(105);
|
||||||
|
for scope in SCOPES {
|
||||||
|
assert!(!has(105, scope), "scope {}", scope);
|
||||||
|
}
|
||||||
|
assert!(get_user_permissions(105).is_empty());
|
||||||
|
assert!(!has(0, ALL));
|
||||||
|
assert!(!has(-1, ALL));
|
||||||
|
wipe(105);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_and_revoke_need_the_scope_and_the_ability() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
for uid in [110, 111, 112, 113] {
|
||||||
|
wipe(uid);
|
||||||
|
}
|
||||||
|
// A grantor with permission.grant and card.upload can hand out
|
||||||
|
// card.upload and nothing else - escalation is impossible
|
||||||
|
insert(110, PERMISSION_GRANT, 0);
|
||||||
|
insert(110, CARD_UPLOAD, 0);
|
||||||
|
grant(111, CARD_UPLOAD, 110).unwrap();
|
||||||
|
grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent
|
||||||
|
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());
|
||||||
|
assert!(!has(111, CARD_EDIT));
|
||||||
|
|
||||||
|
// No permission.grant / permission.revoke - no managing at all
|
||||||
|
insert(112, ALL, 0);
|
||||||
|
insert(113, CARD_UPLOAD, 0);
|
||||||
|
assert!(grant(113, CARD_UPLOAD, 113).is_err());
|
||||||
|
assert!(revoke(112, ALL, 113).is_err());
|
||||||
|
|
||||||
|
// Revoking needs the revoked scope held too
|
||||||
|
insert(110, PERMISSION_REVOKE, 0);
|
||||||
|
assert!(revoke(112, ALL, 110).is_err());
|
||||||
|
assert!(has(112, ALL));
|
||||||
|
revoke(111, CARD_UPLOAD, 110).unwrap();
|
||||||
|
assert!(!has(111, CARD_UPLOAD));
|
||||||
|
|
||||||
|
for uid in [110, 111, 112, 113] {
|
||||||
|
wipe(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_scopes_are_rejected() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(116);
|
||||||
|
wipe(117);
|
||||||
|
insert(116, ALL, 0);
|
||||||
|
assert!(grant(117, "card.explode", 116).is_err());
|
||||||
|
assert!(revoke(117, "card.explode", 116).is_err());
|
||||||
|
assert!(grant(117, "song.upload", 116).is_err());
|
||||||
|
assert!(grant(0, CARD_UPLOAD, 116).is_err());
|
||||||
|
wipe(116);
|
||||||
|
wipe(117);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owners_hold_everything_without_a_row() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(118);
|
||||||
|
wipe(119);
|
||||||
|
crate::runtime::update_owners(&[118, 120]);
|
||||||
|
for scope in SCOPES {
|
||||||
|
assert!(has(118, scope), "scope {}", scope);
|
||||||
|
assert!(has(120, scope), "scope {}", scope);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
insert(118, CARD_UPLOAD, 0);
|
||||||
|
assert!(revoke(118, CARD_UPLOAD, 119).is_err());
|
||||||
|
assert!(has(118, CARD_UPLOAD));
|
||||||
|
// Dropping owner status drops the implicit "*"
|
||||||
|
crate::runtime::update_owners(&[]);
|
||||||
|
assert!(!has(118, CARD_EDIT));
|
||||||
|
wipe(118);
|
||||||
|
wipe(119);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_vocabulary_is_wellformed() {
|
||||||
|
for scope in SCOPES {
|
||||||
|
assert!(!scope.is_empty());
|
||||||
|
assert!(!scope.ends_with('.'));
|
||||||
|
}
|
||||||
|
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE, MV_EDIT] {
|
||||||
|
assert!(SCOPES.contains(&scope), "scope {}", scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/lib.rs
@@ -27,12 +27,28 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
|||||||
let args = get_args();
|
let args = get_args();
|
||||||
let port = args.port;
|
let port = args.port;
|
||||||
|
|
||||||
|
runtime::update_owners(&args.owner);
|
||||||
|
|
||||||
if args.purge {
|
if args.purge {
|
||||||
println!("Purging accounts...");
|
println!("Purging accounts...");
|
||||||
|
// Cabinets first, so the accounts they take with them are gone before
|
||||||
|
// purge_accounts runs its VACUUM over the same database
|
||||||
|
let machines = crate::router::arcade::purge_machines();
|
||||||
|
if machines > 0 {
|
||||||
|
println!("Purged {} arcade machine(s)", machines);
|
||||||
|
}
|
||||||
let ct = crate::router::userdata::purge_accounts();
|
let ct = crate::router::userdata::purge_accounts();
|
||||||
println!("Purged {} accounts", ct);
|
println!("Purged {} accounts", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router::custom_song::migrate::run();
|
||||||
|
router::custom_song::sweep_audio();
|
||||||
|
router::custom_3dmv::sweep_blobs();
|
||||||
|
// 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::multi_live::start_sweeper();
|
||||||
|
|
||||||
let rv = HttpServer::new(|| App::new()
|
let rv = HttpServer::new(|| App::new()
|
||||||
//.wrap(Cors::permissive())
|
//.wrap(Cors::permissive())
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ pub struct Args {
|
|||||||
#[arg(long, default_value_t = false, help = "Enable the custom songs feature (upload/browse/download). Disabled by default; every custom-songs endpoint and webui element is hidden unless this is set")]
|
#[arg(long, default_value_t = false, help = "Enable the custom songs feature (upload/browse/download). Disabled by default; every custom-songs endpoint and webui element is hidden unless this is set")]
|
||||||
pub enable_custom_songs: bool,
|
pub enable_custom_songs: bool,
|
||||||
|
|
||||||
|
#[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 = "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>,
|
||||||
|
|
||||||
#[arg(long, default_value_t = false, help = "Purge dead user accounts on startup")]
|
#[arg(long, default_value_t = false, help = "Purge dead user accounts on startup")]
|
||||||
pub purge: bool,
|
pub purge: bool,
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod start;
|
pub mod start;
|
||||||
|
pub mod arcade;
|
||||||
pub mod global;
|
pub mod global;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod userdata;
|
pub mod userdata;
|
||||||
@@ -10,6 +11,7 @@ pub mod home;
|
|||||||
pub mod lottery;
|
pub mod lottery;
|
||||||
pub mod friend;
|
pub mod friend;
|
||||||
pub mod live;
|
pub mod live;
|
||||||
|
pub mod multi_live;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod story;
|
pub mod story;
|
||||||
@@ -21,6 +23,9 @@ pub mod web;
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod shop;
|
pub mod shop;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
|
pub mod custom_card;
|
||||||
|
pub mod custom_3dmv;
|
||||||
|
pub mod rich_text;
|
||||||
pub mod webui;
|
pub mod webui;
|
||||||
pub mod clear_rate;
|
pub mod clear_rate;
|
||||||
pub mod exchange;
|
pub mod exchange;
|
||||||
@@ -217,11 +222,14 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
|
|||||||
"/api/webui/import" => webui::import(req, body),
|
"/api/webui/import" => webui::import(req, body),
|
||||||
"/api/webui/set_time" => webui::set_time(req, body),
|
"/api/webui/set_time" => webui::set_time(req, body),
|
||||||
"/api/webui/cheat" => webui::cheat(req, body),
|
"/api/webui/cheat" => webui::cheat(req, body),
|
||||||
|
"/api/webui/grantPermission" => webui::grant_permission(req, body),
|
||||||
|
"/api/webui/revokePermission" => webui::revoke_permission(req, body),
|
||||||
|
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
|
||||||
|
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body),
|
||||||
_ => api_req(req, body).await
|
_ => api_req(req, body).await
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match req.path() {
|
match req.path() {
|
||||||
"/web/announcement" => web::announcement(req),
|
|
||||||
"/api/webui/userInfo" => webui::user(req),
|
"/api/webui/userInfo" => webui::user(req),
|
||||||
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
|
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
|
||||||
"/webui/logout" => webui::logout(req),
|
"/webui/logout" => webui::logout(req),
|
||||||
@@ -231,6 +239,13 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
|
|||||||
"/api/webui/listMusic" => webui::get_music_info(req),
|
"/api/webui/listMusic" => webui::get_music_info(req),
|
||||||
"/api/webui/listLoginBonus" => webui::list_login_bonus(req),
|
"/api/webui/listLoginBonus" => webui::list_login_bonus(req),
|
||||||
"/api/webui/listItems" => webui::list_items(req),
|
"/api/webui/listItems" => webui::list_items(req),
|
||||||
|
"/api/webui/listPermissions" => webui::list_permissions(req),
|
||||||
|
"/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_req(req, body).await
|
_ => api_req(req, body).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,9 +265,12 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.route(actix_web::web::get().to(home::gift_get))
|
.route(actix_web::web::get().to(home::gift_get))
|
||||||
.route(actix_web::web::post().to(user::gift))
|
.route(actix_web::web::post().to(user::gift))
|
||||||
)
|
)
|
||||||
|
.configure(arcade::routes)
|
||||||
.configure(card::routes)
|
.configure(card::routes)
|
||||||
.configure(chat::routes)
|
.configure(chat::routes)
|
||||||
.configure(custom_song::routes)
|
.configure(custom_song::routes)
|
||||||
|
.configure(custom_card::routes)
|
||||||
|
.configure(custom_3dmv::routes)
|
||||||
.configure(debug::routes)
|
.configure(debug::routes)
|
||||||
.configure(event::routes)
|
.configure(event::routes)
|
||||||
.configure(exchange::routes)
|
.configure(exchange::routes)
|
||||||
@@ -264,6 +282,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.configure(login::routes)
|
.configure(login::routes)
|
||||||
.configure(lottery::routes)
|
.configure(lottery::routes)
|
||||||
.configure(mission::routes)
|
.configure(mission::routes)
|
||||||
|
.configure(multi_live::routes)
|
||||||
.configure(notice::routes)
|
.configure(notice::routes)
|
||||||
.configure(purchase::routes)
|
.configure(purchase::routes)
|
||||||
.configure(serial_code::routes)
|
.configure(serial_code::routes)
|
||||||
@@ -279,4 +298,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.configure(gree::routes)
|
.configure(gree::routes)
|
||||||
);
|
);
|
||||||
cfg.configure(custom_song::web_routes);
|
cfg.configure(custom_song::web_routes);
|
||||||
|
cfg.configure(custom_card::web_routes);
|
||||||
|
cfg.configure(custom_3dmv::web_routes);
|
||||||
|
cfg.configure(web::routes);
|
||||||
}
|
}
|
||||||
|
|||||||
928
src/router/arcade.rs
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
// Arcade mode: a SIF2 client turned into a rhythm cabinet.
|
||||||
|
//
|
||||||
|
// A cabinet registers once and gets a machine id plus two accounts it owns
|
||||||
|
// forever - the MACHINE account (the identity the attract loop and its demo
|
||||||
|
// lives run on) and one reusable GUEST. Every credit calls /session; without a
|
||||||
|
// card that rewrites the guest back to the starter state in place, keeping its
|
||||||
|
// user id but re-drawing everything a player could have left on it - its login
|
||||||
|
// token included - so the machine plays a clean account and the server never
|
||||||
|
// accumulates dead users. With a card, the card names the account and the play
|
||||||
|
// is real progress on it.
|
||||||
|
//
|
||||||
|
// Credits replace LP: a live flagged `arcade` runs through live_end_ex with
|
||||||
|
// consume_lp = false - the seam /multi_live/end already uses - and its use_lp
|
||||||
|
// pinned to one normal play, so rewards, EXP, bonds, high scores and clears all
|
||||||
|
// record exactly as they do on a phone while LP is never touched.
|
||||||
|
//
|
||||||
|
// That flag is money, so it is not taken on trust. It buys a free play only for
|
||||||
|
// a machine's own two identities, or for a card account inside the window the
|
||||||
|
// credit at /api/arcade/session opened for it, and only when the /live/start
|
||||||
|
// this /live/end answers was itself flagged. See arcade_account_at.
|
||||||
|
//
|
||||||
|
// The whole feature is opt-in (--enable-arcade) and additionally off in
|
||||||
|
// --hidden mode. When disabled every endpoint answers like the custom-song
|
||||||
|
// endpoints do with their flag off - Api(None), as if it never existed - and
|
||||||
|
// nothing touches arcade.db, so no table setup runs.
|
||||||
|
|
||||||
|
use jzon::{object, JsonValue};
|
||||||
|
use actix_web::{web, Responder};
|
||||||
|
|
||||||
|
use crate::router::{databases, global, live, multi_live, userdata, Api, Body};
|
||||||
|
use crate::database::arcade as database;
|
||||||
|
|
||||||
|
// The name a cabinet falls back to when its operator sent nothing usable
|
||||||
|
const DEFAULT_MACHINE_NAME: &str = "ARCADE";
|
||||||
|
|
||||||
|
// Guest accounts are created under this name and renamed to their cabinet's own
|
||||||
|
// name at the first session (design 4.3: a credit plays as the machine)
|
||||||
|
const GUEST_NAME: &str = "GUEST";
|
||||||
|
|
||||||
|
// NESiCA ids are 16 ASCII digits; the cap is generous enough for any other
|
||||||
|
// reader an I/O provider might present without letting an id become a blob
|
||||||
|
const MAX_CARD_ID_LEN: usize = 32;
|
||||||
|
|
||||||
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/arcade")
|
||||||
|
.route("/info", web::get().to(info))
|
||||||
|
.route("/register", web::post().to(register))
|
||||||
|
.route("/session", web::post().to(session))
|
||||||
|
.route("/bind", web::post().to(bind))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disabled() -> bool {
|
||||||
|
let args = crate::get_args();
|
||||||
|
args.hidden || !args.enable_arcade
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days a machine may go unseen before --purge deletes it. 0 means never.
|
||||||
|
fn machine_ttl_days() -> u64 {
|
||||||
|
crate::get_args().arcade_machine_ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
// How long one credit buys LP-free play for the card that paid it. Long enough
|
||||||
|
// that a slow credit never runs out mid-song (the design's play is two songs),
|
||||||
|
// short enough that a card left on a reader overnight is not an open tap.
|
||||||
|
fn session_ttl() -> i64 {
|
||||||
|
crate::get_args().arcade_session_ttl as i64 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live played inside the window pushes it back, so a credit that runs long is
|
||||||
|
// never cut off - but a credit is a credit, and past this many windows from the
|
||||||
|
// session that opened it the extension stops. Without a ceiling one tap of a
|
||||||
|
// card would buy LP-free lives for as long as the player kept starting them.
|
||||||
|
const MAX_SESSION_WINDOWS: i64 = 4;
|
||||||
|
|
||||||
|
// The client writes its request-body flags as 0/1 ints (auto_play, is_omakase,
|
||||||
|
// ...), so `arcade` is read as either that or a JSON bool.
|
||||||
|
fn flag(value: &JsonValue) -> bool {
|
||||||
|
value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card id is an identifier, never text: it is refused rather than sanitised,
|
||||||
|
// because a mangled id would silently name a different card's account.
|
||||||
|
fn card_id(body: &JsonValue) -> Option<String> {
|
||||||
|
let card_id = body["card_id"].as_str().unwrap_or("").trim().to_string();
|
||||||
|
if card_id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if card_id.len() > MAX_CARD_ID_LEN || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(card_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credit is taken: this card is at this cabinet, and for the next ttl it
|
||||||
|
// plays the way a cabinet plays. The single place a window is ever opened -
|
||||||
|
// /api/arcade/session is the one moment the server is told a credit was spent.
|
||||||
|
fn open_card_session(card: &str, machine_id: &str) {
|
||||||
|
database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl());
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account made for a card nobody has named yet takes the card's last four
|
||||||
|
// digits, the way a cabinet prints them on a receipt. The on-cabinet name entry
|
||||||
|
// overwrites it.
|
||||||
|
fn card_account_name(card_id: &str) -> String {
|
||||||
|
let start = card_id.len().saturating_sub(4);
|
||||||
|
card_id[start..].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account this card was issued seconds ago, and which nothing has happened
|
||||||
|
// to since - the only account /api/arcade/session will ever rename.
|
||||||
|
//
|
||||||
|
// The cabinet's name entry is a second /session for the same card: the first one
|
||||||
|
// created the account and answered is_new, the player typed a name, and this is
|
||||||
|
// the same call again carrying it. By then the card is KNOWN, so the rename has
|
||||||
|
// to be told apart from an ordinary card session, and it has to be told apart
|
||||||
|
// hard: a rename that reached a real account would let anyone holding a card id
|
||||||
|
// retitle a stranger's save. Every clause below has to hold.
|
||||||
|
//
|
||||||
|
// Deliberately not a `renamed` column: the account itself carries the proof.
|
||||||
|
fn issued_by_this_credit(card: &str, user_id: i64, now: i64, ttl: i64) -> bool {
|
||||||
|
// The credit that created it is still running, at this card. live_card_session
|
||||||
|
// is the window /api/arcade/session opened and nothing else ever opens
|
||||||
|
// (database/arcade.rs:240), so this is "the same credit, still going". `ttl`
|
||||||
|
// is session_ttl() in production and the test's own number in the test - the
|
||||||
|
// shape purge_machines_before and retire_user_at already use here.
|
||||||
|
let Some((open_card, opened)) = database::live_card_session(user_id, now) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if open_card != card || now - opened > ttl {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A cabinet's own machine or guest identity is never a card's account
|
||||||
|
if database::machine_of_account(user_id).is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A phone has owned it - the same test bind_card's cleanup trusts
|
||||||
|
if userdata::has_transfer_password(user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let user = userdata::get_acc_from_uid(user_id);
|
||||||
|
if user["error"].as_bool().unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// It still carries the placeholder /session gave it, and nothing has been
|
||||||
|
// played on it. Either alone would be enough to make a rename harmless; both
|
||||||
|
// together make it provable.
|
||||||
|
if user["user"]["name"].as_str().unwrap_or("") != card_account_name(card) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one write a rename is. /api/user does exactly this for a phone player
|
||||||
|
// (user.rs:138-140); there is no other name in an account.
|
||||||
|
fn rename_account(user_id: i64, login_token: &str, name: &str) {
|
||||||
|
let mut user = userdata::get_acc_from_uid(user_id);
|
||||||
|
user["user"]["name"] = name.into();
|
||||||
|
userdata::save_acc(login_token, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- endpoints --------------------------------------------------------------
|
||||||
|
|
||||||
|
// The client asks this before it offers to convert a device: a server without
|
||||||
|
// the module answers None and the Title-menu entry refuses.
|
||||||
|
async fn info() -> impl Responder {
|
||||||
|
if disabled() {
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
Api(Some(object!{
|
||||||
|
"enabled": true,
|
||||||
|
"machine_ttl_days": machine_ttl_days()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converting a fresh device into a cabinet. Pre-login by nature: the device has
|
||||||
|
// no account yet, which is exactly the state the Title-menu entry requires.
|
||||||
|
async fn register(Body(body): Body) -> impl Responder {
|
||||||
|
if disabled() {
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
let name = userdata::starter::clean_name(body["name"].as_str().unwrap_or(""), DEFAULT_MACHINE_NAME);
|
||||||
|
|
||||||
|
let Some((machine_user_id, machine_uuid)) = userdata::starter::create(&name) else {
|
||||||
|
return Api(None);
|
||||||
|
};
|
||||||
|
let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else {
|
||||||
|
// Half a cabinet is worse than none: the machine account has nothing
|
||||||
|
// pointing at it and nobody holding its token, so it goes back.
|
||||||
|
userdata::delete_account(machine_user_id);
|
||||||
|
return Api(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let machine_id = database::generate_machine_id();
|
||||||
|
database::insert_machine(&machine_id, &name, machine_user_id, guest_user_id);
|
||||||
|
println!("arcade: registered machine {} \"{}\" (machine {}, guest {})", machine_id, name, machine_user_id, guest_user_id);
|
||||||
|
|
||||||
|
Api(Some(object!{
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"machine_user_id": machine_user_id,
|
||||||
|
"machine_uuid": machine_uuid,
|
||||||
|
"guest_user_id": guest_user_id,
|
||||||
|
"guest_uuid": guest_uuid
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// One credit. Answers the account the player is about to become: the cabinet's
|
||||||
|
// guest (reset in place) or, with a card, the account that card names.
|
||||||
|
//
|
||||||
|
// `name` is the cabinet's on-screen name entry, and it is read in exactly two
|
||||||
|
// places: the account a brand new card is issued, and a rename of that same
|
||||||
|
// account while the credit that created it is still running (see
|
||||||
|
// issued_by_this_credit). A guest credit ignores it - a guest is named after its
|
||||||
|
// cabinet - and so does an ordinary card session.
|
||||||
|
async fn session(Body(body): Body) -> impl Responder {
|
||||||
|
if disabled() {
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
let machine_id = body["machine_id"].as_str().unwrap_or("").to_string();
|
||||||
|
let Some(machine) = database::get_machine(&machine_id) else {
|
||||||
|
println!("arcade: session for unknown machine \"{}\"", machine_id);
|
||||||
|
return Api(None);
|
||||||
|
};
|
||||||
|
database::touch_machine(&machine_id);
|
||||||
|
let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string();
|
||||||
|
|
||||||
|
// A guest credit is a session with no card_id at all (every credit in v1) or
|
||||||
|
// an empty one. Anything else is a card being presented, and a card id that
|
||||||
|
// does not parse is refused rather than quietly played as a guest on
|
||||||
|
// somebody's credit.
|
||||||
|
let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty();
|
||||||
|
let card = card_id(&body);
|
||||||
|
if presented && card.is_none() {
|
||||||
|
println!("arcade: machine {} presented an unusable card id", machine_id);
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
let typed_name = body["name"].as_str().unwrap_or("").trim().to_string();
|
||||||
|
|
||||||
|
let Some(card) = card else {
|
||||||
|
// The cabinet's own guest, rewritten from scratch. Same user id, so the
|
||||||
|
// machine keeps its one guest forever - but a new login token, because
|
||||||
|
// the previous player had a credit's worth of time alone with the old
|
||||||
|
// one. The client adopts the uuid this answer carries (ArcadeEntranceScene
|
||||||
|
// OnSessionResponse -> MngArcadeData.AdoptIdentity), so the rotation is
|
||||||
|
// invisible to it.
|
||||||
|
let guest_user_id = machine["guest_user_id"].as_i64().unwrap_or(0);
|
||||||
|
let Some(uuid) = userdata::starter::reset(guest_user_id, &machine_name) else {
|
||||||
|
println!("arcade: machine {} has no guest account to reset", machine_id);
|
||||||
|
return Api(None);
|
||||||
|
};
|
||||||
|
return Api(Some(object!{
|
||||||
|
"user_id": guest_user_id,
|
||||||
|
"uuid": uuid,
|
||||||
|
"name": machine_name,
|
||||||
|
"is_new": true
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// A known card plays its own account, with every clear it has ever earned
|
||||||
|
if let Some(user_id) = database::card_user(&card) {
|
||||||
|
let uuid = userdata::get_login_token(user_id);
|
||||||
|
if !uuid.is_empty() {
|
||||||
|
// The cabinet's name entry, coming back for the account this credit
|
||||||
|
// just made. Checked BEFORE the window is re-opened, because the
|
||||||
|
// proof is the window the previous call opened.
|
||||||
|
if !typed_name.is_empty() && issued_by_this_credit(&card, user_id, global::timestamp() as i64, session_ttl()) {
|
||||||
|
let named = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
|
||||||
|
rename_account(user_id, &uuid, &named);
|
||||||
|
println!("arcade: machine {} named its new card's account {} \"{}\"", machine_id, user_id, named);
|
||||||
|
}
|
||||||
|
open_card_session(&card, &machine_id);
|
||||||
|
let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string();
|
||||||
|
return Api(Some(object!{
|
||||||
|
"user_id": user_id,
|
||||||
|
"uuid": uuid,
|
||||||
|
"name": name,
|
||||||
|
"is_new": false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// The mapping outlived its account (deleted through the webui, or aged
|
||||||
|
// out with a machine). The card is not broken: it gets a new account.
|
||||||
|
println!("arcade: card mapping pointed at missing account {} - reissuing", user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card nobody has seen before. It gets an account named after its own last
|
||||||
|
// four digits unless the cabinet already has a name for it - which it only
|
||||||
|
// does when something other than the entrance's own two-step asked for one.
|
||||||
|
let name = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
|
||||||
|
let Some((user_id, uuid)) = userdata::starter::create(&name) else {
|
||||||
|
return Api(None);
|
||||||
|
};
|
||||||
|
database::set_card(&card, user_id);
|
||||||
|
open_card_session(&card, &machine_id);
|
||||||
|
println!("arcade: machine {} issued account {} to a new card", machine_id, user_id);
|
||||||
|
|
||||||
|
Api(Some(object!{
|
||||||
|
"user_id": user_id,
|
||||||
|
"uuid": uuid,
|
||||||
|
"name": name,
|
||||||
|
"is_new": true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point a card at an existing (phone) account. The proof is the data-transfer
|
||||||
|
// code and its password - the same check /api/user/gglverifymigrationcode makes
|
||||||
|
// (user.rs:214-220), through the one function that owns that comparison.
|
||||||
|
//
|
||||||
|
// Shared by the cabinet's own /api/arcade/bind and the webui account page's
|
||||||
|
// form: the browser cannot speak the game protocol (encrypted bodies behind the
|
||||||
|
// asset gate), so it gets its own entrance, not its own copy of the rule.
|
||||||
|
pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> {
|
||||||
|
if disabled() {
|
||||||
|
return Err(String::from("Arcade mode is disabled on this server"));
|
||||||
|
}
|
||||||
|
let Some(card) = card_id(&object!{ "card_id": card }) else {
|
||||||
|
return Err(String::from("That is not a usable card id"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let account = userdata::user::migration::get_acc_transfer(migration_code, pass);
|
||||||
|
if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 {
|
||||||
|
return Err(String::from("Transfer code and password don't match"));
|
||||||
|
}
|
||||||
|
let Some(user_id) = account["user_id"].as_i64() else {
|
||||||
|
return Err(String::from("Transfer code and password don't match"));
|
||||||
|
};
|
||||||
|
// A cabinet's own two identities are not player accounts and may never be
|
||||||
|
// behind a card. The guest in particular is rewritten for a stranger every
|
||||||
|
// credit: a card pointing at it would outlive that reset, and /session hands
|
||||||
|
// out the account's current login token to whoever presents the card. The
|
||||||
|
// proof this bind takes - a transfer code and password - is exactly what a
|
||||||
|
// hostile client can register on a guest during its own credit.
|
||||||
|
if database::machine_of_account(user_id).is_some() {
|
||||||
|
return Err(String::from("That account belongs to an arcade cabinet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = database::card_user(&card);
|
||||||
|
database::set_card(&card, user_id);
|
||||||
|
|
||||||
|
// A card that was carrying a throwaway account the cabinet made for it takes
|
||||||
|
// that account with it. Anything the player might care about keeps the card
|
||||||
|
// from taking it: see orphan_of_a_rebound_card.
|
||||||
|
if let Some(previous) = previous
|
||||||
|
&& previous != user_id
|
||||||
|
&& orphan_of_a_rebound_card(previous) {
|
||||||
|
println!("arcade: card {} left empty account {} behind - removing", card, previous);
|
||||||
|
userdata::delete_account(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("arcade: card {} now plays as account {}", card, user_id);
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bind(Body(body): Body) -> impl Responder {
|
||||||
|
match bind_card(
|
||||||
|
body["card_id"].as_str().unwrap_or(""),
|
||||||
|
&body["migrationCode"].to_string(),
|
||||||
|
&body["pass"].to_string()
|
||||||
|
) {
|
||||||
|
Ok(user_id) => Api(Some(object!{ "user_id": user_id })),
|
||||||
|
Err(reason) => {
|
||||||
|
println!("arcade: bind refused - {}", reason);
|
||||||
|
Api(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account /api/arcade/session issued to an unknown card that was then never
|
||||||
|
// used for anything. Every one of these has to hold for it to be thrown away
|
||||||
|
// with the card, because a false positive deletes somebody's save:
|
||||||
|
// * no other card still points at it,
|
||||||
|
// * it is not a cabinet's own machine or guest identity,
|
||||||
|
// * it has never registered a transfer password, so no phone ever owned it,
|
||||||
|
// * and it has no live record at all - nothing was ever played on it.
|
||||||
|
fn orphan_of_a_rebound_card(user_id: i64) -> bool {
|
||||||
|
if database::account_has_card(user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if database::machine_of_account(user_id).is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if userdata::has_transfer_password(user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let user = userdata::get_acc_from_uid(user_id);
|
||||||
|
if user["error"].as_bool().unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- lives ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The account playing on a cabinet right now, None when the `arcade` flag is
|
||||||
|
// just a flag in a request body.
|
||||||
|
//
|
||||||
|
// The flag decides whether the play costs LP, so it is honoured for exactly two
|
||||||
|
// kinds of account:
|
||||||
|
//
|
||||||
|
// * one of a machine's own two identities. The cabinet holds both tokens, the
|
||||||
|
// guest is rewritten from scratch at every credit and the machine account
|
||||||
|
// only ever runs the attract loop, so a free play on either buys nobody
|
||||||
|
// anything.
|
||||||
|
// * an account a card is bound to, and only while the credit that card paid
|
||||||
|
// for is still running: /api/arcade/session opened a window on the card row
|
||||||
|
// and it has not closed yet.
|
||||||
|
//
|
||||||
|
// A card mapping on its own proves nothing - a player can bind a card to their
|
||||||
|
// own account from the webui account page without ever standing in front of a
|
||||||
|
// cabinet - so an account whose card is not at a machine right now is an
|
||||||
|
// ordinary phone account and pays LP, exactly as before.
|
||||||
|
fn cabinet_account_at(login_token: &str, now: i64) -> Option<i64> {
|
||||||
|
let user_id = userdata::uid_from_login_token(login_token);
|
||||||
|
if user_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if database::machine_of_account(user_id).is_some() {
|
||||||
|
return Some(user_id);
|
||||||
|
}
|
||||||
|
database::live_card_session(user_id, now).map(|_| user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same account rule behind the request's own opt-in flag, which /live/start
|
||||||
|
// and /live/end carry. The flag is read first so a phone play never reaches any
|
||||||
|
// of the arcade lookups, nor the module's own on/off switch.
|
||||||
|
fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> {
|
||||||
|
if !flag(&body["arcade"]) || disabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cabinet_account_at(login_token, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account id when this really is an arcade play, None when the live should
|
||||||
|
// run the ordinary way.
|
||||||
|
//
|
||||||
|
// On top of the account rule above, /live/end has to agree with the /live/start
|
||||||
|
// it belongs to. start_live recorded the whole start body (live.rs:331), so the
|
||||||
|
// flag it carried is still there to read: a live that began as an ordinary play
|
||||||
|
// ends as one however its /live/end body is flagged, and a client cannot turn a
|
||||||
|
// finished LP-paid play into a free one after the fact.
|
||||||
|
pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option<i64> {
|
||||||
|
let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?;
|
||||||
|
let Some(started) = live::get_started_live(login_token, body) else {
|
||||||
|
println!("arcade: account {} ended an arcade live that was never started", user_id);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if !flag(&started["arcade"]) {
|
||||||
|
println!("arcade: account {} ended a live it started as an ordinary play", user_id);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cabinet a play is attributed to. A machine's own two accounts belong to
|
||||||
|
// it outright; a card account belongs to nobody, so it is credited to the
|
||||||
|
// machine that most recently ran a session for that card.
|
||||||
|
fn play_machine(user_id: i64) -> Option<String> {
|
||||||
|
if let Some(machine) = database::machine_of_account(user_id) {
|
||||||
|
return machine["machine_id"].as_str().map(str::to_string);
|
||||||
|
}
|
||||||
|
database::last_machine_of_card_account(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A live starting on a cabinet is a sighting for it: last_seen is what the TTL
|
||||||
|
// sweeper measures, and a machine in daily use must never age out under it.
|
||||||
|
//
|
||||||
|
// It is also the credit saying it is still going. A song that runs long - or a
|
||||||
|
// second song of the same credit - pushes the card's window back so the play it
|
||||||
|
// is part of cannot expire underneath it, up to the ceiling above.
|
||||||
|
pub fn live_started(login_token: &str, body: &JsonValue) {
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
let Some(user_id) = arcade_account_at(login_token, body, now) else { return; };
|
||||||
|
if let Some(machine_id) = play_machine(user_id) {
|
||||||
|
database::touch_machine(&machine_id);
|
||||||
|
}
|
||||||
|
if let Some((card, opened)) = database::live_card_session(user_id, now) {
|
||||||
|
let ttl = session_ttl();
|
||||||
|
database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits already paid for the play, so use_lp is no longer what it costs - it
|
||||||
|
// is only what every reward scales off (live.rs:781). Pinned here to one normal
|
||||||
|
// 1x play rather than taken from the request: a cabinet that never spends LP
|
||||||
|
// must not be able to ask for a 10x payout.
|
||||||
|
pub fn live_end_body(body: &JsonValue) -> JsonValue {
|
||||||
|
let mut rv = body.clone();
|
||||||
|
rv["use_lp"] = multi_live::boost_lp(1).into();
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result rank the client's own result screen shows, derived from the live's
|
||||||
|
// own thresholds - the same _scoreC/_scoreB/_scoreA/_scoreS columns the score
|
||||||
|
// missions read (live.rs:465). 4 = S, 3 = A, 2 = B, 1 = C; 0 is below C, and is
|
||||||
|
// also what a custom song gets, having no official thresholds.
|
||||||
|
fn score_rank(live_id: i64, score: i64) -> i64 {
|
||||||
|
let live = &databases::LIVE_LIST[live_id.to_string()];
|
||||||
|
let mut rank = 0;
|
||||||
|
for (index, key) in ["scoreC", "scoreB", "scoreA", "scoreS"].iter().enumerate() {
|
||||||
|
if let Some(threshold) = live[*key].as_i64()
|
||||||
|
&& threshold > 0
|
||||||
|
&& score >= threshold {
|
||||||
|
rank = index as i64 + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cabinet's failed song. The retire wire carries no `arcade` flag - the
|
||||||
|
// client's CJsonSendParamLiveRetire is master_live_id, level and live_score and
|
||||||
|
// nothing else (Protocol.cs:7298-7305) - so the proof that this was a cabinet
|
||||||
|
// play is the start it belongs to: start_live recorded the whole /live/start
|
||||||
|
// body, and a cabinet's start is flagged. The account rule on top is /live/end's,
|
||||||
|
// unchanged.
|
||||||
|
//
|
||||||
|
// Read before live.rs's live_retire, which sweeps the very record this reads.
|
||||||
|
pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option<i64> {
|
||||||
|
// The start record is read before the module's on/off switch so an ordinary
|
||||||
|
// player's retire - whose start was never flagged - costs one lookup and
|
||||||
|
// nothing else, exactly as it did before the ledger learned about failures.
|
||||||
|
let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?;
|
||||||
|
if disabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> {
|
||||||
|
let started = live::get_started_live(login_token, body)?;
|
||||||
|
if !flag(&started["arcade"]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cabinet_account_at(login_token, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row in the cabinet's ledger, and a sighting for it. Records nothing when
|
||||||
|
// the account has no cabinet to attribute the play to - a card bound through the
|
||||||
|
// webui that has never been to a machine still plays, it is just not anyone's
|
||||||
|
// bookkeeping.
|
||||||
|
//
|
||||||
|
// `cleared` is false for a song reported at /live/retire because its life gauge
|
||||||
|
// emptied. It is recorded whatever its play_time, unlike the global clear-rate
|
||||||
|
// counter live.rs:30 gates at five seconds: that gate keeps a rage-quit out of a
|
||||||
|
// public board, while a credit's song is a song of that credit either way. The
|
||||||
|
// score is the one the retire carried, and the rank is what that score is worth
|
||||||
|
// against the live's own thresholds - `cleared` is what tells the two apart.
|
||||||
|
pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) {
|
||||||
|
let Some(machine_id) = play_machine(user_id) else { return; };
|
||||||
|
let live_id = body["master_live_id"].as_i64().unwrap_or(0);
|
||||||
|
let level = body["level"].as_i64().unwrap_or(0);
|
||||||
|
let score = body["live_score"]["score"].as_i64().unwrap_or(0);
|
||||||
|
database::insert_play(&machine_id, user_id, live_id, level, score, score_rank(live_id, score), cleared);
|
||||||
|
database::touch_machine(&machine_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- maintenance ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Machines unseen for --arcade-machine-ttl days, deleted with the two accounts
|
||||||
|
// each of them owns. Run from the --purge sweep at boot.
|
||||||
|
//
|
||||||
|
// A card-bound player account is never touched here, and neither is a machine
|
||||||
|
// or guest account somebody has bound a card to: cards outlive cabinets by
|
||||||
|
// design, and the account behind one is a player's.
|
||||||
|
pub fn purge_machines() -> usize {
|
||||||
|
if disabled() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let ttl = machine_ttl_days();
|
||||||
|
// 0 is "never age a cabinet out", not "age every cabinet out this second"
|
||||||
|
if ttl == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
purge_machines_before(global::timestamp() as i64 - (ttl as i64 * 24 * 60 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn purge_machines_before(cutoff: i64) -> usize {
|
||||||
|
let dead = database::machines_last_seen_before(cutoff);
|
||||||
|
for machine in dead.members() {
|
||||||
|
let machine_id = machine["machine_id"].as_str().unwrap_or("");
|
||||||
|
println!(
|
||||||
|
"Removing arcade machine {} (last seen {})",
|
||||||
|
machine_id,
|
||||||
|
global::format_datetime(machine["last_seen"].as_u64().unwrap_or(0))
|
||||||
|
);
|
||||||
|
for key in ["machine_user_id", "guest_user_id"] {
|
||||||
|
let user_id = machine[key].as_i64().unwrap_or(0);
|
||||||
|
if user_id != 0 && !database::account_has_card(user_id) {
|
||||||
|
userdata::delete_account(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database::delete_machine(machine_id);
|
||||||
|
}
|
||||||
|
dead.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- webui ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The operator's machine list: name, id, last seen and how many lives the
|
||||||
|
// cabinet has recorded.
|
||||||
|
pub fn webui_machines() -> JsonValue {
|
||||||
|
if disabled() {
|
||||||
|
return jzon::array![];
|
||||||
|
}
|
||||||
|
database::list_machines()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retiring a cabinet by hand: the same deletion the TTL sweeper performs, with
|
||||||
|
// the same rule about accounts a card has claimed.
|
||||||
|
pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> {
|
||||||
|
if disabled() {
|
||||||
|
return Err(String::from("Arcade mode is disabled on this server"));
|
||||||
|
}
|
||||||
|
let Some(machine) = database::get_machine(machine_id) else {
|
||||||
|
return Err(format!("No arcade machine {}", machine_id));
|
||||||
|
};
|
||||||
|
for key in ["machine_user_id", "guest_user_id"] {
|
||||||
|
let user_id = machine[key].as_i64().unwrap_or(0);
|
||||||
|
if user_id != 0 && !database::account_has_card(user_id) {
|
||||||
|
userdata::delete_account(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database::delete_machine(machine_id);
|
||||||
|
println!("arcade: machine {} removed through the webui", machine_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The flag the client sends as 0/1 and the flag it might send as a bool are
|
||||||
|
// the same flag; nothing else is
|
||||||
|
#[test]
|
||||||
|
fn the_arcade_flag_reads_both_shapes() {
|
||||||
|
assert!(flag(&true.into()));
|
||||||
|
assert!(flag(&(1).into()));
|
||||||
|
assert!(!flag(&false.into()));
|
||||||
|
assert!(!flag(&(0).into()));
|
||||||
|
assert!(!flag(&JsonValue::Null));
|
||||||
|
assert!(!flag(&"yes".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card id is refused rather than repaired
|
||||||
|
#[test]
|
||||||
|
fn card_ids_are_validated_not_sanitised() {
|
||||||
|
assert_eq!(card_id(&object!{ card_id: "0123456789012345" }).as_deref(), Some("0123456789012345"));
|
||||||
|
assert_eq!(card_id(&object!{ card_id: " 0123456789012345 " }).as_deref(), Some("0123456789012345"));
|
||||||
|
assert!(card_id(&object!{}).is_none());
|
||||||
|
assert!(card_id(&object!{ card_id: "" }).is_none());
|
||||||
|
assert!(card_id(&object!{ card_id: "0123-4567" }).is_none());
|
||||||
|
assert!(card_id(&object!{ card_id: "'; DROP TABLE cards--" }).is_none());
|
||||||
|
assert!(card_id(&object!{ card_id: "x".repeat(MAX_CARD_ID_LEN + 1) }).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account a fresh card gets is named after the card
|
||||||
|
#[test]
|
||||||
|
fn a_new_card_is_named_after_its_last_four_digits() {
|
||||||
|
assert_eq!(card_account_name("0123456789012345"), "2345");
|
||||||
|
assert_eq!(card_account_name("12"), "12");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rank stored in the ledger is the one the result screen shows, read off
|
||||||
|
// the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000,
|
||||||
|
// A 250000, S 350000)
|
||||||
|
#[test]
|
||||||
|
fn the_ledger_rank_comes_from_the_lives_own_thresholds() {
|
||||||
|
assert_eq!(score_rank(1100101, 0), 0);
|
||||||
|
assert_eq!(score_rank(1100101, 19_999), 0);
|
||||||
|
assert_eq!(score_rank(1100101, 20_000), 1);
|
||||||
|
assert_eq!(score_rank(1100101, 100_000), 2);
|
||||||
|
assert_eq!(score_rank(1100101, 250_000), 3);
|
||||||
|
assert_eq!(score_rank(1100101, 350_000), 4);
|
||||||
|
assert_eq!(score_rank(1100101, 9_999_999), 4);
|
||||||
|
// A custom song has no official row, so it has no rank
|
||||||
|
assert_eq!(score_rank(10_000, 9_999_999), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card-rebind cleanup only ever takes an account that is provably a
|
||||||
|
// throwaway: a real player's account survives losing its card
|
||||||
|
#[test]
|
||||||
|
fn only_an_untouched_throwaway_follows_its_card() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
// Never played, no card, no password, not a cabinet: a throwaway
|
||||||
|
let (orphan, _) = userdata::starter::create("2345").unwrap();
|
||||||
|
assert!(orphan_of_a_rebound_card(orphan));
|
||||||
|
|
||||||
|
// Somebody played on it
|
||||||
|
let (played, played_token) = userdata::starter::create("2346").unwrap();
|
||||||
|
let mut user = userdata::get_acc_from_uid(played);
|
||||||
|
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
|
||||||
|
userdata::save_acc(&played_token, user);
|
||||||
|
assert!(!orphan_of_a_rebound_card(played));
|
||||||
|
|
||||||
|
// Somebody can take it over from a phone
|
||||||
|
let (phone, _) = userdata::starter::create("2347").unwrap();
|
||||||
|
userdata::user::migration::save_acc_transfer(phone, "hunter2");
|
||||||
|
assert!(!orphan_of_a_rebound_card(phone));
|
||||||
|
|
||||||
|
// Another card still points at it
|
||||||
|
let (shared, _) = userdata::starter::create("2348").unwrap();
|
||||||
|
crate::database::arcade::set_card("9999888877776666", shared);
|
||||||
|
assert!(!orphan_of_a_rebound_card(shared));
|
||||||
|
|
||||||
|
// It is a cabinet's own identity
|
||||||
|
let (machine_account, _) = userdata::starter::create("Cabinet").unwrap();
|
||||||
|
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
let machine_id = crate::database::arcade::generate_machine_id();
|
||||||
|
crate::database::arcade::insert_machine(&machine_id, "Cabinet", machine_account, guest_account);
|
||||||
|
assert!(!orphan_of_a_rebound_card(machine_account));
|
||||||
|
assert!(!orphan_of_a_rebound_card(guest_account));
|
||||||
|
|
||||||
|
// An account that no longer exists is not deleted twice
|
||||||
|
userdata::delete_account(orphan);
|
||||||
|
assert!(!orphan_of_a_rebound_card(orphan));
|
||||||
|
|
||||||
|
crate::database::arcade::delete_machine(&machine_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cabinet's song that ended with an empty life gauge is reported at
|
||||||
|
// /live/retire, which carries no arcade flag - the start record is what
|
||||||
|
// proves it was a cabinet's - and it lands in the ledger as a failed song
|
||||||
|
// rather than not at all.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_cabinet_song_lands_in_the_ledger_uncleared() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
use crate::database::arcade as db;
|
||||||
|
|
||||||
|
let (machine_account, _) = userdata::starter::create("Cabinet Retire").unwrap();
|
||||||
|
let (guest_account, guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
let machine_id = db::generate_machine_id();
|
||||||
|
db::insert_machine(&machine_id, "Cabinet Retire", machine_account, guest_account);
|
||||||
|
|
||||||
|
// The credit's song: /live/start carried the flag, so the record does
|
||||||
|
let start = object!{
|
||||||
|
master_live_id: 1100101,
|
||||||
|
level: 4,
|
||||||
|
deck_slot: 1,
|
||||||
|
live_boost: 1,
|
||||||
|
arcade: true
|
||||||
|
};
|
||||||
|
live::start_live(&guest_token, &start);
|
||||||
|
|
||||||
|
// The life gauge emptied: the client played it out and reported the score
|
||||||
|
// it reached at /live/retire, which has no arcade flag of its own
|
||||||
|
let retire = object!{
|
||||||
|
master_live_id: 1100101,
|
||||||
|
level: 4,
|
||||||
|
live_score: { score: 123_456, max_combo: 40, play_time: 93 }
|
||||||
|
};
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
let user_id = retire_user_at(&guest_token, &retire, now).expect("a cabinet's retire was not recognised");
|
||||||
|
assert_eq!(user_id, guest_account);
|
||||||
|
record_play(user_id, &retire, false);
|
||||||
|
|
||||||
|
let ledger = db::plays_of_machine(&machine_id);
|
||||||
|
assert_eq!(ledger.len(), 1);
|
||||||
|
assert_eq!(ledger[0]["cleared"].as_bool(), Some(false));
|
||||||
|
assert_eq!(ledger[0]["user_id"].as_i64(), Some(guest_account));
|
||||||
|
assert_eq!(ledger[0]["live_id"].as_i64(), Some(1100101));
|
||||||
|
assert_eq!(ledger[0]["level"].as_i64(), Some(4));
|
||||||
|
assert_eq!(ledger[0]["score"].as_i64(), Some(123_456), "the retire's own score was not carried");
|
||||||
|
// It counts as a song of the credit, and only the cleared count excludes it
|
||||||
|
assert!(db::list_machines().members().any(|m|
|
||||||
|
m["machine_id"] == machine_id.as_str() && m["play_count"] == 1 && m["cleared_count"] == 0
|
||||||
|
));
|
||||||
|
|
||||||
|
// A live that was started as an ordinary play is not a cabinet's song,
|
||||||
|
// however the retire that ends it looks
|
||||||
|
live::start_live(&guest_token, &object!{ master_live_id: 1100102, level: 4, deck_slot: 1 });
|
||||||
|
assert!(retire_user_at(&guest_token, &object!{
|
||||||
|
master_live_id: 1100102,
|
||||||
|
level: 4,
|
||||||
|
live_score: { score: 1, max_combo: 1, play_time: 93 }
|
||||||
|
}, now).is_none(), "an unflagged start was bookkept as a cabinet's song");
|
||||||
|
|
||||||
|
// Neither is a retire with no start behind it at all
|
||||||
|
assert!(retire_user_at(&guest_token, &object!{
|
||||||
|
master_live_id: 1100103,
|
||||||
|
level: 4,
|
||||||
|
live_score: { score: 1, max_combo: 1, play_time: 93 }
|
||||||
|
}, now).is_none());
|
||||||
|
|
||||||
|
db::delete_machine(&machine_id);
|
||||||
|
userdata::delete_account(machine_account);
|
||||||
|
userdata::delete_account(guest_account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cabinet that aged out takes its two accounts with it - and nothing
|
||||||
|
// else. A cabinet still in use, and any account a player bound a card to,
|
||||||
|
// survives the sweep.
|
||||||
|
#[test]
|
||||||
|
fn an_aged_out_cabinet_takes_only_its_own_accounts() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
use crate::database::arcade as db;
|
||||||
|
|
||||||
|
// Old: unseen since before the cutoff
|
||||||
|
let (old_machine, old_machine_token) = userdata::starter::create("Old cabinet").unwrap();
|
||||||
|
let (old_guest, old_guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
let old_id = db::generate_machine_id();
|
||||||
|
db::insert_machine(&old_id, "Old cabinet", old_machine, old_guest);
|
||||||
|
|
||||||
|
// Live: seen just now
|
||||||
|
let (live_machine, live_machine_token) = userdata::starter::create("Live cabinet").unwrap();
|
||||||
|
let (live_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
let live_id = db::generate_machine_id();
|
||||||
|
db::insert_machine(&live_id, "Live cabinet", live_machine, live_guest);
|
||||||
|
|
||||||
|
// Old too, but somebody bound a card to its machine account
|
||||||
|
let (claimed_machine, claimed_machine_token) = userdata::starter::create("Claimed cabinet").unwrap();
|
||||||
|
let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
let claimed_id = db::generate_machine_id();
|
||||||
|
db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest);
|
||||||
|
db::set_card("4444333322221111", claimed_machine);
|
||||||
|
|
||||||
|
// A player account with a card, belonging to no cabinet at all
|
||||||
|
let (player, player_token) = userdata::starter::create("Player").unwrap();
|
||||||
|
db::set_card("8888777766665555", player);
|
||||||
|
|
||||||
|
// Everything registered above is "seen now"; only the two we push back
|
||||||
|
// are older than the cutoff
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
for id in [&old_id, &claimed_id] {
|
||||||
|
db::backdate_machine_for_test(id, now - 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(purge_machines_before(now - 500), 2);
|
||||||
|
|
||||||
|
// The aged-out cabinet is gone, accounts and all
|
||||||
|
assert!(db::get_machine(&old_id).is_none());
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&old_machine_token), 0);
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&old_guest_token), 0);
|
||||||
|
|
||||||
|
// The cabinet still in use is untouched
|
||||||
|
assert!(db::get_machine(&live_id).is_some());
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&live_machine_token), live_machine);
|
||||||
|
|
||||||
|
// The claimed cabinet is retired, but the account behind its card is not
|
||||||
|
assert!(db::get_machine(&claimed_id).is_none());
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine);
|
||||||
|
assert_eq!(db::card_user("4444333322221111"), Some(claimed_machine));
|
||||||
|
|
||||||
|
// A card-bound player account is never a candidate in the first place
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&player_token), player);
|
||||||
|
|
||||||
|
db::delete_machine(&live_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card the server has never seen gets an account named after its own last
|
||||||
|
// four digits, and the name the player then types at the cabinet lands on
|
||||||
|
// that account - and on nothing else. The rename is a second /session for
|
||||||
|
// the same card, so every other account has to be immune to it.
|
||||||
|
#[test]
|
||||||
|
fn a_new_cards_account_takes_the_name_the_player_types() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
use crate::database::arcade as db;
|
||||||
|
|
||||||
|
const TTL: i64 = 30 * 60;
|
||||||
|
let now = global::timestamp() as i64;
|
||||||
|
let card = "6060606060602345";
|
||||||
|
let machine_id = db::generate_machine_id();
|
||||||
|
|
||||||
|
// /api/arcade/session, first call: an unknown card, so the account is
|
||||||
|
// created under the card's last four digits and the credit's window opens
|
||||||
|
let name = card_account_name(card);
|
||||||
|
assert_eq!(name, "2345");
|
||||||
|
let (user_id, token) = userdata::starter::create(&name).unwrap();
|
||||||
|
db::set_card(card, user_id);
|
||||||
|
db::open_card_session(card, &machine_id, now + TTL);
|
||||||
|
|
||||||
|
// ...and the same call again, carrying the name the player typed
|
||||||
|
assert!(issued_by_this_credit(card, user_id, now, TTL));
|
||||||
|
rename_account(user_id, &token, "Ethan");
|
||||||
|
assert_eq!(userdata::get_name_and_rank(user_id)["user_name"].as_str(), Some("Ethan"));
|
||||||
|
|
||||||
|
// Once named it is somebody's account: a second name entry on the same
|
||||||
|
// card cannot touch it
|
||||||
|
assert!(!issued_by_this_credit(card, user_id, now, TTL), "a named account was still renameable");
|
||||||
|
|
||||||
|
// Neither can one whose credit has ended - the window is the proof
|
||||||
|
let later_card = "6060606060601111";
|
||||||
|
let (later, _) = userdata::starter::create(&card_account_name(later_card)).unwrap();
|
||||||
|
db::set_card(later_card, later);
|
||||||
|
db::open_card_session(later_card, &machine_id, now + TTL);
|
||||||
|
db::backdate_card_session_for_test(later_card, now - TTL - 1, now + TTL);
|
||||||
|
assert!(!issued_by_this_credit(later_card, later, now, TTL), "an old credit could still rename");
|
||||||
|
db::backdate_card_session_for_test(later_card, now, now - 1);
|
||||||
|
assert!(!issued_by_this_credit(later_card, later, now, TTL), "a closed window could still rename");
|
||||||
|
|
||||||
|
// Nor a card presented at one cabinet renaming through another card's row
|
||||||
|
db::open_card_session(later_card, &machine_id, now + TTL);
|
||||||
|
assert!(!issued_by_this_credit(card, later, now, TTL), "a different card's id could rename");
|
||||||
|
|
||||||
|
// A phone account behind a card is never renameable, however fresh its
|
||||||
|
// window is: it has a transfer password, and its name is its own
|
||||||
|
let phone_card = "6060606060602222";
|
||||||
|
let (phone, _) = userdata::starter::create(&card_account_name(phone_card)).unwrap();
|
||||||
|
userdata::user::migration::save_acc_transfer(phone, "hunter2");
|
||||||
|
db::set_card(phone_card, phone);
|
||||||
|
db::open_card_session(phone_card, &machine_id, now + TTL);
|
||||||
|
assert!(!issued_by_this_credit(phone_card, phone, now, TTL), "a phone account was renameable");
|
||||||
|
|
||||||
|
// And neither is an account that has already played something
|
||||||
|
let played_card = "6060606060603333";
|
||||||
|
let (played, played_token) = userdata::starter::create(&card_account_name(played_card)).unwrap();
|
||||||
|
db::set_card(played_card, played);
|
||||||
|
db::open_card_session(played_card, &machine_id, now + TTL);
|
||||||
|
assert!(issued_by_this_credit(played_card, played, now, TTL));
|
||||||
|
let mut user = userdata::get_acc_from_uid(played);
|
||||||
|
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
|
||||||
|
userdata::save_acc(&played_token, user);
|
||||||
|
assert!(!issued_by_this_credit(played_card, played, now, TTL), "a played account was renameable");
|
||||||
|
|
||||||
|
// Finally: a cabinet's own identity, which a card may not name at all
|
||||||
|
let (machine_account, _) = userdata::starter::create("Cabinet Name").unwrap();
|
||||||
|
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
|
||||||
|
db::insert_machine(&machine_id, "Cabinet Name", machine_account, guest_account);
|
||||||
|
let cabinet_card = "6060606060604444";
|
||||||
|
db::set_card(cabinet_card, guest_account);
|
||||||
|
db::open_card_session(cabinet_card, &machine_id, now + TTL);
|
||||||
|
assert!(!issued_by_this_credit(cabinet_card, guest_account, now, TTL), "a cabinet identity was renameable");
|
||||||
|
|
||||||
|
db::delete_machine(&machine_id);
|
||||||
|
for id in [user_id, later, phone, played, machine_account, guest_account] {
|
||||||
|
userdata::delete_account(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,11 @@ pub fn account_has_custom_cards(user: &JsonValue) -> bool {
|
|||||||
user["card_list"].members().any(|card| is_custom(card["master_card_id"].as_i64().unwrap_or(0)))
|
user["card_list"].members().any(|card| is_custom(card["master_card_id"].as_i64().unwrap_or(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Card rows come through custom_card::card_info: the baked CARD_LIST for
|
||||||
|
// official/imported ids, the custom-card db for the runtime band - a runtime
|
||||||
|
// card has no CARD_LIST row and would otherwise cap at 0 here
|
||||||
fn exp_cap(master_card_id: i64, evolved: bool) -> i64 {
|
fn exp_cap(master_card_id: i64, evolved: bool) -> i64 {
|
||||||
let card = &databases::CARD_LIST[master_card_id.to_string()];
|
let card = crate::router::custom_card::card_info(master_card_id);
|
||||||
let rarity = card["rarity"].to_string();
|
let rarity = card["rarity"].to_string();
|
||||||
let curve = card["masterCardLevelId"].as_i64().unwrap_or(0);
|
let curve = card["masterCardLevelId"].as_i64().unwrap_or(0);
|
||||||
let max_level = if evolved {
|
let max_level = if evolved {
|
||||||
@@ -46,7 +49,7 @@ fn exp_cap(master_card_id: i64, evolved: bool) -> i64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn skill_exp_cap(master_card_id: i64) -> i64 {
|
fn skill_exp_cap(master_card_id: i64) -> i64 {
|
||||||
let card = &databases::CARD_LIST[master_card_id.to_string()];
|
let card = crate::router::custom_card::card_info(master_card_id);
|
||||||
let rarity = card["rarity"].to_string();
|
let rarity = card["rarity"].to_string();
|
||||||
let skill_curve = databases::CARD_RARITY[&rarity]["masterCardSkillLevelId"].to_string();
|
let skill_curve = databases::CARD_RARITY[&rarity]["masterCardSkillLevelId"].to_string();
|
||||||
databases::CARD_SKILL_MAX[skill_curve].as_i64().unwrap_or(i64::MAX)
|
databases::CARD_SKILL_MAX[skill_curve].as_i64().unwrap_or(i64::MAX)
|
||||||
|
|||||||
@@ -7,11 +7,33 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
|||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("/chat")
|
web::scope("/chat")
|
||||||
.route("/home", web::post().to(home))
|
.route("/home", web::post().to(home))
|
||||||
|
.route("/talk/get_stamp", web::get().to(get_stamp))
|
||||||
.route("/talk/start", web::post().to(start))
|
.route("/talk/start", web::post().to(start))
|
||||||
.route("/talk/end", web::post().to(end))
|
.route("/talk/end", web::post().to(end))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stamps this account owns. ew does not track stamp unlocks, so everyone has the
|
||||||
|
// masterdata initial set — the same list /chat/home reports, deliberately from one
|
||||||
|
// source so the two endpoints can never disagree.
|
||||||
|
fn owned_stamp_ids() -> JsonValue {
|
||||||
|
databases::INITIAL_CHAT_STAMPS.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/chat/talk/get_stamp (Protocol.send_get_stamp, FuncId.GET_STAMP).
|
||||||
|
// RecvGetStampRData carries a single `master_chat_stamp_ids` array, which its Notify()
|
||||||
|
// feeds to CallOnUpdateChatStampListNotify — the same sink RecvChatHomeRData uses, so
|
||||||
|
// the stamp picker ends up with whatever this returns. The client sends no parameters.
|
||||||
|
//
|
||||||
|
// This endpoint appears in neither official capture (0 hits across the 288MB JP and
|
||||||
|
// 1.4GB EN logs), so the shape is taken from the client class and the contents from the
|
||||||
|
// /chat/home captures that do exist and carry the same field.
|
||||||
|
async fn get_stamp(Login(_key): Login) -> impl Responder {
|
||||||
|
Api(Some(object!{
|
||||||
|
"master_chat_stamp_ids": owned_stamp_ids()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool {
|
pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool {
|
||||||
for data in chats.members() {
|
for data in chats.members() {
|
||||||
if data["chat_id"] == id && data["room_id"] == num {
|
if data["chat_id"] == id && data["room_id"] == num {
|
||||||
@@ -48,7 +70,7 @@ async fn home(Login(key): Login) -> impl Responder {
|
|||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"progress_list": chats,
|
"progress_list": chats,
|
||||||
"master_chat_room_ids": rooms,
|
"master_chat_room_ids": rooms,
|
||||||
"master_chat_stamp_ids": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002],
|
"master_chat_stamp_ids": owned_stamp_ids(),
|
||||||
"master_chat_attachment_ids": []
|
"master_chat_attachment_ids": []
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -75,3 +97,41 @@ async fn end(Session { key, body }: Session) -> impl Responder {
|
|||||||
|
|
||||||
Api(Some(array![]))
|
Api(Some(array![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// get_stamp serve masterdata instead of a hardcoded literal.
|
||||||
|
const OFFICIAL_INITIAL_STAMPS: [i64; 97] = [
|
||||||
|
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30,
|
||||||
|
31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58,
|
||||||
|
59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,
|
||||||
|
85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_initial_stamp_set_matches_official() {
|
||||||
|
let ids: Vec<i64> = owned_stamp_ids().members().map(|s| s.as_i64().unwrap()).collect();
|
||||||
|
assert_eq!(ids, OFFICIAL_INITIAL_STAMPS.to_vec());
|
||||||
|
// Order matters: the official list is masterdata order, not sorted (the 11xxxxxx
|
||||||
|
// band trails the small ids).
|
||||||
|
assert_eq!(ids.first(), Some(&1));
|
||||||
|
assert_eq!(ids.last(), Some(&44001002));
|
||||||
|
// The gaps are real - 18, 42 and 47 are not initial stamps.
|
||||||
|
for missing in [18, 42, 47] {
|
||||||
|
assert!(!ids.contains(&missing), "{missing} should not be an initial stamp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_stamp_and_chat_home_cannot_disagree() {
|
||||||
|
// Both endpoints read the one source; this is what stops /chat/home's list and
|
||||||
|
// the stamp picker's list from drifting apart.
|
||||||
|
assert_eq!(owned_stamp_ids(), *databases::INITIAL_CHAT_STAMPS);
|
||||||
|
assert!(!owned_stamp_ids().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ fn setup_tables(conn: &rusqlite::Connection) {
|
|||||||
);").unwrap();
|
);").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_live_score(id: i64, uid: i64, score: i64) {
|
// Merges this play into the song's top-10 board. Pure so the whole read-modify-write can
|
||||||
if uid == 0 || score == 0 {
|
// sit inside one transaction, and so the keep-best rule is testable on its own.
|
||||||
return;
|
//
|
||||||
}
|
// The board is per-song, not per-account: `scores` is keyed by live_id alone and the user
|
||||||
|
// lives inside the JSON blob. A user already on the board keeps whichever of their two
|
||||||
let info = DATABASE.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(id)).unwrap_or(String::from("[]"));
|
// scores is higher — a replay that does not beat the stored one is dropped (`None`), which
|
||||||
let scores = jzon::parse(&info).unwrap();
|
// is what makes a repeated or duplicated end idempotent rather than additive.
|
||||||
|
fn merge_live_score(scores: &JsonValue, uid: i64, score: i64) -> Option<JsonValue> {
|
||||||
let mut result = array![];
|
let mut result = array![];
|
||||||
let mut current = 0;
|
let mut current = 0;
|
||||||
let mut added = false;
|
let mut added = false;
|
||||||
@@ -99,7 +99,8 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if scores[i]["user"].as_i64().unwrap() == uid && !added {
|
if scores[i]["user"].as_i64().unwrap() == uid && !added {
|
||||||
return;
|
// Already on the board with a better score — keep it, drop this play.
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
if scores[i]["user"].as_i64().unwrap() == uid {
|
if scores[i]["user"].as_i64().unwrap() == uid {
|
||||||
continue;
|
continue;
|
||||||
@@ -108,12 +109,40 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
|
|||||||
current += 1;
|
current += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if added {
|
if added { Some(result) } else { None }
|
||||||
if DATABASE.lock_and_select("SELECT live_id FROM scores WHERE live_id=?1", params!(id)).is_ok() {
|
|
||||||
DATABASE.lock_and_exec("UPDATE scores SET score_data=?1 WHERE live_id=?2", params!(jzon::stringify(result), id));
|
|
||||||
} else {
|
|
||||||
DATABASE.lock_and_exec("INSERT INTO scores (score_data, live_id) VALUES (?1, ?2)", params!(jzon::stringify(result), id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_live_score(id: i64, uid: i64, score: i64) {
|
||||||
|
if uid == 0 || score == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One transaction for read + merge + write. Previously this SELECTed to choose between
|
||||||
|
// UPDATE and INSERT on separate connections, so two ends landing together for a song
|
||||||
|
// with no row yet both took the INSERT branch and the loser panicked the worker on
|
||||||
|
// `UNIQUE constraint failed: scores.live_id`. Two clients finishing the same multi
|
||||||
|
// live make that the normal case, not a rare race.
|
||||||
|
let write = DATABASE.lock_and_transact(|conn| {
|
||||||
|
let stored: String = conn
|
||||||
|
.query_row("SELECT score_data FROM scores WHERE live_id=?1", params!(id), |row| row.get(0))
|
||||||
|
.unwrap_or_else(|_| String::from("[]"));
|
||||||
|
let scores = jzon::parse(&stored).unwrap_or_else(|_| array![]);
|
||||||
|
|
||||||
|
let Some(result) = merge_live_score(&scores, uid, score) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atomic upsert: no branch left for a concurrent writer to slip between.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO scores (live_id, score_data) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(live_id) DO UPDATE SET score_data=excluded.score_data",
|
||||||
|
params!(id, jzon::stringify(result))
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = write {
|
||||||
|
println!("Failed to record score for live {id}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,27 +158,44 @@ pub fn invalidate_cache() {
|
|||||||
crate::lock_onto_mutex!(CACHED_HTML_DATA).take();
|
crate::lock_onto_mutex!(CACHED_HTML_DATA).take();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The clear-rate counter column this play lands in, or None for a level outside 1-4.
|
||||||
|
// Names come from this closed set, never from request data, so it is safe to interpolate.
|
||||||
|
fn clear_rate_column(level: i32, failed: bool) -> Option<&'static str> {
|
||||||
|
let tier = match level {
|
||||||
|
1 => "normal",
|
||||||
|
2 => "hard",
|
||||||
|
3 => "expert",
|
||||||
|
4 => "master",
|
||||||
|
_ => return None
|
||||||
|
};
|
||||||
|
Some(match (tier, failed) {
|
||||||
|
("normal", true) => "normal_failed", ("normal", false) => "normal_pass",
|
||||||
|
("hard", true) => "hard_failed", ("hard", false) => "hard_pass",
|
||||||
|
("expert", true) => "expert_failed", ("expert", false) => "expert_pass",
|
||||||
|
(_, true) => "master_failed", (_, false) => "master_pass"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
|
pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
|
||||||
update_live_score(id, uid, score);
|
update_live_score(id, uid, score);
|
||||||
match DATABASE.get_live_data(id) {
|
|
||||||
Ok(info) => {
|
|
||||||
let value = format!("{}_{}",
|
|
||||||
if 1 == level { "normal" } else if 2 == level { "hard" } else if 3 == level { "expert" } else { "master" },
|
|
||||||
if failed { "failed" } else { "pass" }
|
|
||||||
);
|
|
||||||
let new_info = if 1 == level && failed { info.normal_failed }
|
|
||||||
else if 1 == level && !failed { info.normal_pass }
|
|
||||||
else if 2 == level && failed { info.hard_failed }
|
|
||||||
else if 2 == level && !failed { info.hard_pass }
|
|
||||||
else if 3 == level && failed { info.expert_failed }
|
|
||||||
else if 3 == level && !failed { info.expert_pass }
|
|
||||||
else if 4 == level && failed { info.master_failed }
|
|
||||||
else if 4 == level && !failed { info.master_pass } else { return; };
|
|
||||||
|
|
||||||
DATABASE.lock_and_exec(&format!("UPDATE lives SET {}=?1 WHERE live_id=?2", value), params!(new_info + 1, info.live_id));
|
let Some(column) = clear_rate_column(level, failed) else {
|
||||||
},
|
return;
|
||||||
Err(_) => {
|
};
|
||||||
DATABASE.lock_and_exec("INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass, expert_failed, expert_pass, master_failed, master_pass) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params!(
|
|
||||||
|
// `lives` is keyed by live_id alone and had the same select-then-INSERT-or-UPDATE
|
||||||
|
// split as `scores`, so it could panic the same way on a first-ever concurrent play
|
||||||
|
// (and lost counts whenever two plays overlapped). One upsert does both branches
|
||||||
|
// atomically and increments in SQL rather than read-modify-write in Rust.
|
||||||
|
let write = DATABASE.lock_and_transact(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass,
|
||||||
|
expert_failed, expert_pass, master_failed, master_pass)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
|
ON CONFLICT(live_id) DO UPDATE SET {column} = {column} + 1"
|
||||||
|
),
|
||||||
|
params!(
|
||||||
id,
|
id,
|
||||||
if 1 == level && failed { 1 } else { 0 },
|
if 1 == level && failed { 1 } else { 0 },
|
||||||
if 1 == level && !failed { 1 } else { 0 },
|
if 1 == level && !failed { 1 } else { 0 },
|
||||||
@@ -159,9 +205,14 @@ pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
|
|||||||
if 3 == level && !failed { 1 } else { 0 },
|
if 3 == level && !failed { 1 } else { 0 },
|
||||||
if 4 == level && failed { 1 } else { 0 },
|
if 4 == level && failed { 1 } else { 0 },
|
||||||
if 4 == level && !failed { 1 } else { 0 }
|
if 4 == level && !failed { 1 } else { 0 }
|
||||||
));
|
)
|
||||||
},
|
)?;
|
||||||
};
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = write {
|
||||||
|
println!("Failed to record clear rate for live {id}: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_song_title(live_id: i32, english: bool) -> String {
|
fn get_song_title(live_id: i32, english: bool) -> String {
|
||||||
@@ -173,9 +224,22 @@ fn get_song_title(live_id: i32, english: bool) -> String {
|
|||||||
if !details.is_null() {
|
if !details.is_null() {
|
||||||
return details["name"].to_string();
|
return details["name"].to_string();
|
||||||
}
|
}
|
||||||
|
// Custom songs aren't in the official music mst (their live_id ==
|
||||||
|
// music_id). PUBLIC ones show their real title; private/shared ones are
|
||||||
|
// already filtered out of the page and would stay "Unknown Song" even if
|
||||||
|
// one slipped through - the lookup only ever answers for public songs
|
||||||
|
if let Some(title) = crate::router::custom_song::public_song_title(live_id as i64, english) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
String::from("Unknown Song")
|
String::from("Unknown Song")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Titles land inside HTML text and attributes; custom song names are user
|
||||||
|
// input, so escape them (official names contain nothing that needs it)
|
||||||
|
fn html_escape(text: &str) -> String {
|
||||||
|
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_pass_percent(failed: i64, pass: i64) -> String {
|
fn get_pass_percent(failed: i64, pass: i64) -> String {
|
||||||
let total = (failed + pass) as f64;
|
let total = (failed + pass) as f64;
|
||||||
if failed + pass == 0 {
|
if failed + pass == 0 {
|
||||||
@@ -255,7 +319,7 @@ pub async fn clearrate(req: HttpRequest) -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
let self_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let self_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
let live = body["master_live_id"].as_i64().unwrap();
|
let live = body["master_live_id"].as_i64().unwrap();
|
||||||
|
|
||||||
@@ -266,15 +330,13 @@ pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl R
|
|||||||
|
|
||||||
for (i, data) in scores.members().enumerate() {
|
for (i, data) in scores.members().enumerate() {
|
||||||
let uid = data["user"].as_i64().unwrap();
|
let uid = data["user"].as_i64().unwrap();
|
||||||
let user = guest::get_user(uid, &object![], guest::UserView::Ranking, custom_cards);
|
let user = guest::get_user(uid, &object![], guest::UserView::Ranking, protocol);
|
||||||
let user_obj = if uid == self_id {
|
let user_obj = if uid == self_id {
|
||||||
// The client wants the fields get_user hides from other players
|
// The client wants the fields get_user hides from other players
|
||||||
let mut self_user = object!{
|
let mut self_user = object!{
|
||||||
user: userdata::get_acc_from_uid(uid)["user"].clone()
|
user: userdata::get_acc_from_uid(uid)["user"].clone()
|
||||||
};
|
};
|
||||||
if !custom_cards {
|
guest::proxy_user_cards(&mut self_user, protocol);
|
||||||
guest::proxy_user_cards(&mut self_user);
|
|
||||||
}
|
|
||||||
self_user["user"].clone()
|
self_user["user"].clone()
|
||||||
} else {
|
} else {
|
||||||
user["user"].clone()
|
user["user"].clone()
|
||||||
@@ -317,8 +379,8 @@ fn get_html() -> JsonValue {
|
|||||||
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
|
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
|
||||||
};
|
};
|
||||||
|
|
||||||
let title_jp = get_song_title(info.live_id, false);
|
let title_jp = html_escape(&get_song_title(info.live_id, false));
|
||||||
let title_en = get_song_title(info.live_id, true);
|
let title_en = html_escape(&get_song_title(info.live_id, true));
|
||||||
|
|
||||||
let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass);
|
let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass);
|
||||||
let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass);
|
let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass);
|
||||||
@@ -396,3 +458,107 @@ pub async fn clearrate_html(_req: HttpRequest) -> HttpResponse {
|
|||||||
.content_type(ContentType::html())
|
.content_type(ContentType::html())
|
||||||
.body(html)
|
.body(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn board(live_id: i64) -> JsonValue {
|
||||||
|
let stored = DATABASE
|
||||||
|
.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(live_id))
|
||||||
|
.unwrap_or_else(|_| String::from("[]"));
|
||||||
|
jzon::parse(&stored).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn passes(live_id: i64) -> i64 {
|
||||||
|
DATABASE.get_live_data(live_id).map(|l| l.master_pass).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_keeps_the_users_best_score() {
|
||||||
|
let existing = array![object!{user: 7, score: 900}];
|
||||||
|
|
||||||
|
// A better score replaces the stored one rather than adding a second entry.
|
||||||
|
let better = merge_live_score(&existing, 7, 1000).expect("a better score is recorded");
|
||||||
|
assert_eq!(better.len(), 1);
|
||||||
|
assert_eq!(better[0]["score"].as_i64(), Some(1000));
|
||||||
|
|
||||||
|
// A worse or equal replay is dropped entirely — this is what makes a repeated
|
||||||
|
// end idempotent instead of appending the same user twice.
|
||||||
|
assert!(merge_live_score(&existing, 7, 800).is_none());
|
||||||
|
assert!(merge_live_score(&existing, 7, 900).is_none());
|
||||||
|
|
||||||
|
// A different user is ranked against the board, best first.
|
||||||
|
let other = merge_live_score(&existing, 8, 950).expect("a new user is recorded");
|
||||||
|
assert_eq!(other.len(), 2);
|
||||||
|
assert_eq!(other[0]["user"].as_i64(), Some(8));
|
||||||
|
assert_eq!(other[1]["user"].as_i64(), Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_end_does_not_double_the_board_entry() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let live_id = 990001;
|
||||||
|
|
||||||
|
live_completed(live_id, 4, false, 500000, 4242);
|
||||||
|
assert_eq!(board(live_id).len(), 1);
|
||||||
|
assert_eq!(passes(live_id), 1);
|
||||||
|
|
||||||
|
// The second end for the same session: same user, same score. The board must not
|
||||||
|
// grow, and this must not raise UNIQUE constraint failed: scores.live_id.
|
||||||
|
live_completed(live_id, 4, false, 500000, 4242);
|
||||||
|
assert_eq!(board(live_id).len(), 1, "the same user must not appear twice");
|
||||||
|
assert_eq!(board(live_id)[0]["score"].as_i64(), Some(500000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual regression: two ends for a song with no row yet, landing together. Both
|
||||||
|
// used to take the INSERT branch and the loser unwrapped a ConstraintViolation into a
|
||||||
|
// worker panic. Two clients finishing one multi live makes this the normal case.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_first_plays_of_one_song_do_not_collide() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let live_id = 990002;
|
||||||
|
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for uid in [101i64, 102, 103, 104, 105, 106, 107, 108] {
|
||||||
|
s.spawn(move || live_completed(live_id, 4, false, 400000 + uid, uid));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every writer landed: no row lost to a race, none lost to a swallowed error.
|
||||||
|
assert_eq!(board(live_id).len(), 8);
|
||||||
|
assert_eq!(passes(live_id), 8, "clear-rate counts must not be lost either");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of /multi_live/end's score-board branch (the account's own high score
|
||||||
|
// is pinned in live.rs): a public multi live reaches live_completed with uid 0, so the
|
||||||
|
// play is counted and the board is left alone. /live/retire has always used the same
|
||||||
|
// signal for a failed live.
|
||||||
|
#[test]
|
||||||
|
fn a_play_with_no_user_counts_the_clear_but_not_the_board() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let live_id = 990003;
|
||||||
|
|
||||||
|
live_completed(live_id, 4, false, 500000, 0);
|
||||||
|
assert_eq!(board(live_id).len(), 0, "an unranked play must not reach the board");
|
||||||
|
assert_eq!(passes(live_id), 1, "but it is still a play of the song");
|
||||||
|
|
||||||
|
// The same score from a real account does land, which is the private-room path.
|
||||||
|
live_completed(live_id, 4, false, 500000, 4243);
|
||||||
|
assert_eq!(board(live_id).len(), 1);
|
||||||
|
assert_eq!(passes(live_id), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_rate_columns_cover_every_level() {
|
||||||
|
assert_eq!(clear_rate_column(1, false), Some("normal_pass"));
|
||||||
|
assert_eq!(clear_rate_column(1, true), Some("normal_failed"));
|
||||||
|
assert_eq!(clear_rate_column(2, false), Some("hard_pass"));
|
||||||
|
assert_eq!(clear_rate_column(3, true), Some("expert_failed"));
|
||||||
|
assert_eq!(clear_rate_column(4, false), Some("master_pass"));
|
||||||
|
assert_eq!(clear_rate_column(4, true), Some("master_failed"));
|
||||||
|
// Level 0 (a skip ticket's "any level") writes no counter, as before.
|
||||||
|
assert_eq!(clear_rate_column(0, false), None);
|
||||||
|
assert_eq!(clear_rate_column(5, false), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1504
src/router/custom_3dmv.rs
Normal file
88
src/router/custom_3dmv/package.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut file = archive.by_name(name).ok()?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes).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"))?;
|
||||||
|
|
||||||
|
let manifest = read_entry(&mut archive, "manifest.json").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)) {
|
||||||
|
fields.insert(format!("{}_{}", role, slot), bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in ["camera", "config", "stage"] {
|
||||||
|
if let Some(bytes) = read_entry(&mut archive, name) {
|
||||||
|
fields.insert(String::from(name), bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
121
src/router/custom_3dmv/vmd.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
2277
src/router/custom_card.rs
Normal file
321
src/router/custom_card/art.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
// Art derivation for custom cards: every in-game kind is generated from one
|
||||||
|
// source artwork per variant, replicating the SIF1-import pipeline that built
|
||||||
|
// the 7 kinds for all 4,002 imported cards
|
||||||
|
// (sif1-cards/pipeline/07_prototype.py + 08_convert_all.py + 15_memoria_art.py
|
||||||
|
// + 17_skillcutin_art.py + 18_blank_art_fallback.py). Uploads are never
|
||||||
|
// rejected for dimensions: sources and per-kind overrides alike are
|
||||||
|
// center-crop-covered and Lanczos3-resampled to the exact target sizes, and
|
||||||
|
// the stored/hashed bytes are always the processed PNG output.
|
||||||
|
//
|
||||||
|
// Two source treatments, matching the pipeline's two art classes:
|
||||||
|
// * a TRANSPARENT cutout (SIF1 "navi" standee art): the figure (alpha bbox)
|
||||||
|
// is placed on the official c_/h_ canvas geometry (fit 1700x1180, centred
|
||||||
|
// on 2048x1260) over a blurred+darkened backdrop made from the art itself;
|
||||||
|
// m_/sc_/p_/r_ are the pipeline's figure-proportional crops.
|
||||||
|
// * OPAQUE artwork: landscape art covers the c_ canvas directly; portrait art
|
||||||
|
// gets the blurred-backdrop presentation with the whole image centred.
|
||||||
|
// h_ falls back to the c_ content and m_ to its top square, exactly the
|
||||||
|
// fallback semantics 18_blank_art_fallback.py shipped for the 74 SIF1
|
||||||
|
// cards with no transparent standee.
|
||||||
|
|
||||||
|
use image::{imageops, DynamicImage, Rgba, RgbaImage};
|
||||||
|
|
||||||
|
// Permissive sanity floor: upscaling small-ish sources is allowed, only
|
||||||
|
// absurd inputs are refused
|
||||||
|
pub const MIN_SOURCE_DIM: u32 = 100;
|
||||||
|
|
||||||
|
// c_/h_ canvas and the navi fit box (make_full_illust / place_navi)
|
||||||
|
const CANVAS_W: u32 = 2048;
|
||||||
|
const CANVAS_H: u32 = 1260;
|
||||||
|
const FIT_W: f64 = 1700.0;
|
||||||
|
const FIT_H: f64 = 1180.0;
|
||||||
|
const BRIGHTNESS: f64 = 0.88;
|
||||||
|
// GaussianBlur(24) @ 2048x1260 == GaussianBlur(6) @ 512x315 upscaled (the
|
||||||
|
// pipeline used the same half-res trick for speed)
|
||||||
|
const BLUR_W: u32 = 512;
|
||||||
|
const BLUR_H: u32 = 315;
|
||||||
|
const BLUR_SIGMA: f32 = 6.0;
|
||||||
|
|
||||||
|
// m_: head-anchored square of the placed figure (15_memoria_art.py, fitted
|
||||||
|
// against official pairs at alpha-IoU 0.89-0.94)
|
||||||
|
const M_SIDE: f64 = 0.78; // of figure height
|
||||||
|
const M_TOP: f64 = 0.03; // top edge this far ABOVE the figure top
|
||||||
|
|
||||||
|
// sc_: bust crop (17_skillcutin_art.py, fitted at alpha-IoU 0.84-0.92)
|
||||||
|
const SC_CROP_H: f64 = 0.68; // of figure height
|
||||||
|
const SC_CROP_TOP: f64 = -0.055; // of figure height, relative to figure top
|
||||||
|
|
||||||
|
pub fn decode_source(name: &str, bytes: &[u8]) -> Result<DynamicImage, String> {
|
||||||
|
let img = image::load_from_memory(bytes)
|
||||||
|
.map_err(|_| format!("'{}' is not a decodable image (png, jpg and webp work)", name))?;
|
||||||
|
if img.width() < MIN_SOURCE_DIM || img.height() < MIN_SOURCE_DIM {
|
||||||
|
return Err(format!("'{}' is only {}x{} - at least {}x{} is required", name, img.width(), img.height(), MIN_SOURCE_DIM, MIN_SOURCE_DIM));
|
||||||
|
}
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_png(img: &RgbaImage) -> Result<Vec<u8>, String> {
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
DynamicImage::ImageRgba8(img.clone())
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(img: &RgbaImage, w: u32, h: u32) -> RgbaImage {
|
||||||
|
imageops::resize(img, w.max(1), h.max(1), imageops::FilterType::Lanczos3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale-to-cover + centre-crop: the aspect-mismatch treatment for every
|
||||||
|
// override and for opaque landscape sources (the pipeline's cover())
|
||||||
|
pub fn cover(img: &RgbaImage, tw: u32, th: u32) -> RgbaImage {
|
||||||
|
let scale = f64::max(tw as f64 / img.width() as f64, th as f64 / img.height() as f64);
|
||||||
|
let scaled = resize(img, (img.width() as f64 * scale).round() as u32, (img.height() as f64 * scale).round() as u32);
|
||||||
|
let x = (scaled.width() - tw.min(scaled.width())) / 2;
|
||||||
|
let y = (scaled.height() - th.min(scaled.height())) / 2;
|
||||||
|
imageops::crop_imm(&scaled, x, y, tw, th).to_image()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bounding box of the non-transparent pixels
|
||||||
|
fn alpha_bbox(img: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
|
||||||
|
for (x, y, px) in img.enumerate_pixels() {
|
||||||
|
if px[3] > 0 {
|
||||||
|
x0 = x0.min(x);
|
||||||
|
y0 = y0.min(y);
|
||||||
|
x1 = x1.max(x);
|
||||||
|
y1 = y1.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x0 == u32::MAX {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((x0, y0, x1 + 1, y1 + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source counts as a transparent cutout (SIF1 navi-style standee) when a
|
||||||
|
// meaningful share of it is fully transparent background
|
||||||
|
fn is_cutout(img: &RgbaImage) -> bool {
|
||||||
|
let total = (img.width() as u64) * (img.height() as u64);
|
||||||
|
let transparent = img.pixels().filter(|px| px[3] < 16).count() as u64;
|
||||||
|
transparent * 100 / total.max(1) >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplicative darken (PIL ImageEnhance.Brightness)
|
||||||
|
fn darken(img: &mut RgbaImage, factor: f64) {
|
||||||
|
for px in img.pixels_mut() {
|
||||||
|
for c in 0..3 {
|
||||||
|
px[c] = (px[c] as f64 * factor).round().min(255.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop that pads with transparency outside the source (PIL crop() semantics),
|
||||||
|
// so figure-proportional crops can reach past the figure
|
||||||
|
fn crop_pad(src: &RgbaImage, x0: i64, y0: i64, w: u32, h: u32) -> RgbaImage {
|
||||||
|
let mut canvas = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 0]));
|
||||||
|
let sx = x0.max(0) as u32;
|
||||||
|
let sy = y0.max(0) as u32;
|
||||||
|
if sx < src.width() && sy < src.height() {
|
||||||
|
let cw = (src.width() - sx).min((x0 + w as i64 - sx as i64).max(0) as u32);
|
||||||
|
let ch = (src.height() - sy).min((y0 + h as i64 - sy as i64).max(0) as u32);
|
||||||
|
if cw > 0 && ch > 0 {
|
||||||
|
let piece = imageops::crop_imm(src, sx, sy, cw, ch).to_image();
|
||||||
|
imageops::overlay(&mut canvas, &piece, (sx as i64 - x0).max(0), (sy as i64 - y0).max(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blurred + darkened backdrop the c_ presentation puts behind portrait /
|
||||||
|
// cutout art, made from the art itself (the import used the SIF1 card
|
||||||
|
// background here; a runtime upload has no separate background, and the
|
||||||
|
// art-as-its-own-backdrop is the same trick the custom-song jacket blur uses)
|
||||||
|
fn blurred_backdrop(source: &RgbaImage) -> RgbaImage {
|
||||||
|
let mut flat = flatten(source);
|
||||||
|
flat = cover(&flat, BLUR_W, BLUR_H);
|
||||||
|
flat = imageops::blur(&flat, BLUR_SIGMA);
|
||||||
|
darken(&mut flat, BRIGHTNESS);
|
||||||
|
resize(&flat, CANVAS_W, CANVAS_H)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten transparency onto the mean colour of the opaque pixels (the
|
||||||
|
// pipeline flattened onto the tile's centre colour; a cutout's centre may be
|
||||||
|
// transparent, so the figure's own average is the stable equivalent)
|
||||||
|
fn flatten(img: &RgbaImage) -> RgbaImage {
|
||||||
|
let (mut r, mut g, mut b, mut n) = (0u64, 0u64, 0u64, 0u64);
|
||||||
|
for px in img.pixels() {
|
||||||
|
if px[3] >= 128 {
|
||||||
|
r += px[0] as u64;
|
||||||
|
g += px[1] as u64;
|
||||||
|
b += px[2] as u64;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let base = if n > 0 {
|
||||||
|
Rgba([(r / n) as u8, (g / n) as u8, (b / n) as u8, 255])
|
||||||
|
} else {
|
||||||
|
Rgba([240, 240, 240, 255])
|
||||||
|
};
|
||||||
|
let mut canvas = RgbaImage::from_pixel(img.width(), img.height(), base);
|
||||||
|
imageops::overlay(&mut canvas, img, 0, 0);
|
||||||
|
canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// place_navi: the figure scaled into the 1700x1180 fit box, and its placement
|
||||||
|
// on the 2048x1260 canvas
|
||||||
|
fn place_figure(figure: &RgbaImage) -> (RgbaImage, i64, i64) {
|
||||||
|
let scale = f64::min(FIT_H / figure.height() as f64, FIT_W / figure.width() as f64);
|
||||||
|
let placed = resize(figure, (figure.width() as f64 * scale).round() as u32, (figure.height() as f64 * scale).round() as u32);
|
||||||
|
let x = (CANVAS_W as i64 - placed.width() as i64) / 2;
|
||||||
|
let y = (CANVAS_H as i64 - placed.height() as i64) / 2;
|
||||||
|
(placed, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// p_: the 136x508 vertical strip through the figure's centre column
|
||||||
|
fn portrait_strip(figure: &RgbaImage) -> RgbaImage {
|
||||||
|
let scaled = resize(figure, ((figure.width() as f64 * 508.0 / figure.height() as f64).round() as u32).max(1), 508);
|
||||||
|
let cx = scaled.width() as i64 / 2;
|
||||||
|
crop_pad(&scaled, cx - 68, 0, 136, 508)
|
||||||
|
}
|
||||||
|
|
||||||
|
// m_/r_: head-anchored square of the figure (side 0.78 * height, top edge
|
||||||
|
// 0.03 * height above the figure)
|
||||||
|
fn head_square(figure: &RgbaImage) -> RgbaImage {
|
||||||
|
let h = figure.height() as f64;
|
||||||
|
let side = (h * M_SIDE).round().max(1.0) as u32;
|
||||||
|
let ox = (figure.width() as i64 - side as i64) / 2;
|
||||||
|
let oy = -(h * M_TOP).round() as i64;
|
||||||
|
crop_pad(figure, ox, oy, side, side)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sc_: the 2:1 bust crop, bottom edge running off the figure
|
||||||
|
fn bust_crop(figure: &RgbaImage) -> RgbaImage {
|
||||||
|
let fh = figure.height() as f64;
|
||||||
|
let ch = (fh * SC_CROP_H).round().max(1.0) as u32;
|
||||||
|
let cw = ch * 2;
|
||||||
|
let x0 = figure.width() as i64 / 2 - cw as i64 / 2;
|
||||||
|
let y0 = (fh * SC_CROP_TOP).round() as i64;
|
||||||
|
resize(&crop_pad(figure, x0, y0, cw, ch), 1024, 512)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 7 card kinds from one source artwork. Every output is at the exact
|
||||||
|
// official size; the caller overlays any explicit per-kind overrides
|
||||||
|
pub fn derive_card_art(source: &DynamicImage) -> Vec<(&'static str, RgbaImage)> {
|
||||||
|
let rgba = source.to_rgba8();
|
||||||
|
let cutout = is_cutout(&rgba).then(|| alpha_bbox(&rgba)).flatten();
|
||||||
|
|
||||||
|
let (c, h, figure) = if let Some((x0, y0, x1, y1)) = cutout {
|
||||||
|
// Transparent standee: the official composition
|
||||||
|
let figure = imageops::crop_imm(&rgba, x0, y0, x1 - x0, y1 - y0).to_image();
|
||||||
|
let (placed, px, py) = place_figure(&figure);
|
||||||
|
let mut c = blurred_backdrop(&rgba);
|
||||||
|
imageops::overlay(&mut c, &placed, px, py);
|
||||||
|
let mut h = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
|
||||||
|
imageops::overlay(&mut h, &placed, px, py);
|
||||||
|
(c, h, figure)
|
||||||
|
} else {
|
||||||
|
// Opaque artwork: landscape covers the canvas, portrait gets the
|
||||||
|
// blurred-backdrop presentation; h_ falls back to the c_ content
|
||||||
|
let c = if rgba.height() > rgba.width() {
|
||||||
|
let (placed, px, py) = place_figure(&rgba);
|
||||||
|
let mut c = blurred_backdrop(&rgba);
|
||||||
|
imageops::overlay(&mut c, &placed, px, py);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
cover(&rgba, CANVAS_W, CANVAS_H)
|
||||||
|
};
|
||||||
|
(c.clone(), c, rgba)
|
||||||
|
};
|
||||||
|
|
||||||
|
let t = resize(&c, 512, 315);
|
||||||
|
let m = resize(&head_square(&figure), 380, 380);
|
||||||
|
let r = resize(&head_square(&figure), 256, 256);
|
||||||
|
let sc = bust_crop(&figure);
|
||||||
|
let p = portrait_strip(&figure);
|
||||||
|
|
||||||
|
vec![("c", c), ("h", h), ("t", t), ("p", p), ("r", r), ("m", m), ("sc", sc)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character icon derived from the portrait when no explicit icon is supplied:
|
||||||
|
// a top-anchored square for portrait sources (the face is up there), centred
|
||||||
|
// for landscape ones
|
||||||
|
pub fn derive_character_icon(portrait: &DynamicImage) -> RgbaImage {
|
||||||
|
let rgba = portrait.to_rgba8();
|
||||||
|
let side = rgba.width().min(rgba.height());
|
||||||
|
let x0 = (rgba.width() - side) / 2;
|
||||||
|
let y0 = if rgba.height() > rgba.width() { 0 } else { (rgba.height() - side) / 2 };
|
||||||
|
resize(&imageops::crop_imm(&rgba, x0, y0, side, side).to_image(), 230, 230)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cutout_source(w: u32, h: u32) -> DynamicImage {
|
||||||
|
// A transparent canvas with an opaque figure occupying the middle band
|
||||||
|
let mut img = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 0]));
|
||||||
|
for y in h / 8..h * 7 / 8 {
|
||||||
|
for x in w / 3..w * 2 / 3 {
|
||||||
|
img.put_pixel(x, y, Rgba([200, 60, 120, 255]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DynamicImage::ImageRgba8(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opaque_source(w: u32, h: u32) -> DynamicImage {
|
||||||
|
DynamicImage::ImageRgba8(RgbaImage::from_fn(w, h, |x, y| {
|
||||||
|
Rgba([(x % 256) as u8, (y % 256) as u8, 99, 255])
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGETS: &[(&str, u32, u32)] = &[
|
||||||
|
("c", 2048, 1260), ("h", 2048, 1260), ("t", 512, 315), ("p", 136, 508),
|
||||||
|
("r", 256, 256), ("m", 380, 380), ("sc", 1024, 512)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Odd-sized sources in -> every kind at its exact official size out,
|
||||||
|
// through both the cutout and the opaque path, portrait and landscape
|
||||||
|
#[test]
|
||||||
|
fn derivation_hits_exact_target_dims() {
|
||||||
|
for source in [cutout_source(437, 1013), opaque_source(437, 1013), opaque_source(1900, 700), opaque_source(300, 300)] {
|
||||||
|
let derived = derive_card_art(&source);
|
||||||
|
assert_eq!(derived.len(), 7);
|
||||||
|
for (kind, img) in derived {
|
||||||
|
let target = TARGETS.iter().find(|(k, _, _)| *k == kind).unwrap();
|
||||||
|
assert_eq!((img.width(), img.height()), (target.1, target.2), "kind {}", kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let icon = derive_character_icon(&opaque_source(431, 617));
|
||||||
|
assert_eq!((icon.width(), icon.height()), (230, 230));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transparent standee gets a transparent h_ aligned with c_; an opaque
|
||||||
|
// source falls back to h_ == c_ (18_blank_art_fallback semantics)
|
||||||
|
#[test]
|
||||||
|
fn cutouts_and_opaque_sources_take_their_own_lanes() {
|
||||||
|
let derived = derive_card_art(&cutout_source(800, 1000));
|
||||||
|
let h = &derived.iter().find(|(k, _)| *k == "h").unwrap().1;
|
||||||
|
assert!(h.pixels().any(|px| px[3] == 0), "cutout h_ keeps transparency");
|
||||||
|
let c = &derived.iter().find(|(k, _)| *k == "c").unwrap().1;
|
||||||
|
assert!(c.pixels().all(|px| px[3] == 255), "c_ backdrop is opaque");
|
||||||
|
|
||||||
|
let derived = derive_card_art(&opaque_source(800, 1000));
|
||||||
|
let h = &derived.iter().find(|(k, _)| *k == "h").unwrap().1;
|
||||||
|
let c = &derived.iter().find(|(k, _)| *k == "c").unwrap().1;
|
||||||
|
assert_eq!(h.as_raw(), c.as_raw(), "opaque h_ falls back to c_");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cover_crops_to_aspect_and_floor_rejects_tiny_sources() {
|
||||||
|
let img = opaque_source(1000, 400).to_rgba8();
|
||||||
|
let out = cover(&img, 512, 315);
|
||||||
|
assert_eq!((out.width(), out.height()), (512, 315));
|
||||||
|
|
||||||
|
let mut tiny = Vec::new();
|
||||||
|
opaque_source(32, 32).write_to(&mut std::io::Cursor::new(&mut tiny), image::ImageFormat::Png).unwrap();
|
||||||
|
assert!(decode_source("art_00", &tiny).unwrap_err().contains("at least"));
|
||||||
|
assert!(decode_source("art_00", b"garbage").unwrap_err().contains("not a decodable image"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
mod audio;
|
// audio is shared: custom_card voicelines transcode through the same
|
||||||
|
// in-process symphonia + vorbis machinery
|
||||||
|
pub mod audio;
|
||||||
mod chart;
|
mod chart;
|
||||||
|
// One-time startup regroup of charts stored before the spawn-group pairing rule; called from
|
||||||
|
// run_server, no-op when the feature is disabled or every chart is already correctly grouped
|
||||||
|
pub mod migrate;
|
||||||
mod package;
|
mod package;
|
||||||
|
|
||||||
use jzon::{array, object, JsonValue};
|
use jzon::{array, object, JsonValue};
|
||||||
@@ -11,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::router::{global, userdata, webui, Login, Api};
|
use crate::router::{global, rich_text, userdata, webui, Login, Api};
|
||||||
use crate::database::custom_song as database;
|
use crate::database::custom_song as database;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
use crate::lock_onto_mutex;
|
use crate::lock_onto_mutex;
|
||||||
@@ -97,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder {
|
|||||||
return Api(None);
|
return Api(None);
|
||||||
}
|
}
|
||||||
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
|
let mut songs = database::get_songs_for_user(uid);
|
||||||
|
for song in songs.members_mut() {
|
||||||
|
// Additive field: the client turns it into the song's detail-info credit line (the
|
||||||
|
// staff-credits text the live loading screen and the music library show). Old clients
|
||||||
|
// that don't know the field simply ignore it. The name is an ACCOUNT name, which the
|
||||||
|
// profile route stores verbatim, so it is stripped of rich-text tags before it lands in
|
||||||
|
// a TMP rich-text field (rich_text.rs)
|
||||||
|
let Some(music_id) = song["music_id"].as_i64() else { continue; };
|
||||||
|
let owner = database::get_song_owner(music_id).unwrap_or(0);
|
||||||
|
let name = userdata::get_name_and_rank(owner)["user_name"].as_str().unwrap_or("").to_string();
|
||||||
|
song["uploader"] = rich_text::strip_tags(&name).into();
|
||||||
|
}
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"revision": database::get_revision(),
|
"revision": database::get_revision(),
|
||||||
"songs": database::get_songs_for_user(uid)
|
"songs": songs
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +136,15 @@ pub fn hidden_live_ids() -> JsonValue {
|
|||||||
database::non_public_music_ids()
|
database::non_public_music_ids()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The clear-rate page shows real titles for PUBLIC custom songs; anything
|
||||||
|
// else stays exactly as hidden as before
|
||||||
|
pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||||
|
if disabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
database::public_song_title(music_id, english)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
|
pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
|
||||||
if disabled() {
|
if disabled() {
|
||||||
return array![];
|
return array![];
|
||||||
@@ -126,6 +152,18 @@ pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
|
|||||||
database::non_public_music_ids_for(uid)
|
database::non_public_music_ids_for(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether `uid` may attach cross-feature content (a custom 3D MV) to this
|
||||||
|
// song: it must exist, and be theirs or publicly visible. Mirrors
|
||||||
|
// custom_card::validate_character_ref
|
||||||
|
pub fn can_reference_song(uid: i64, music_id: i64) -> Result<(), String> {
|
||||||
|
if !disabled()
|
||||||
|
&& database::get_song_owner(music_id).is_some()
|
||||||
|
&& (database::get_song_owner(music_id) == Some(uid) || database::song_publicly_visible(music_id)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!("Unknown music_id '{}'", music_id))
|
||||||
|
}
|
||||||
|
|
||||||
fn song_path(music_id: i64, file: &str) -> String {
|
fn song_path(music_id: i64, file: &str) -> String {
|
||||||
get_data_path(&format!("custom_songs/{}/{}", music_id, file))
|
get_data_path(&format!("custom_songs/{}/{}", music_id, file))
|
||||||
}
|
}
|
||||||
@@ -134,6 +172,68 @@ fn audio_file_path(md5: &str) -> String {
|
|||||||
get_data_path(&format!("custom_songs/audio/{}.ogg", md5))
|
get_data_path(&format!("custom_songs/audio/{}.ogg", md5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Startup GC for the content-addressed audio store. Every ogg in there belongs
|
||||||
|
// to some song's play or select cue; the only things that write the directory
|
||||||
|
// are upload and update, so an unreferenced file is a leftover from an
|
||||||
|
// interrupted one. Deleting it loses nothing - the same audio uploaded again
|
||||||
|
// re-encodes to the same bytes at the same path (audio.rs).
|
||||||
|
//
|
||||||
|
// Deliberately conservative, because the failure mode is deleting audio a live
|
||||||
|
// song serves: anything that makes the reference set doubtful - an unreadable
|
||||||
|
// catalog, a blob that won't parse, a song with no cue md5 - aborts the whole
|
||||||
|
// sweep instead of treating that song as referencing nothing. Only exactly
|
||||||
|
// {32 hex}.ogg names are ever considered. Takes UPLOAD_LOCK for the same
|
||||||
|
// reason delete does: an upload that has written its oggs but not yet inserted
|
||||||
|
// its row must not look like an orphan.
|
||||||
|
pub fn sweep_audio() {
|
||||||
|
if disabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
|
let Some(blobs) = database::all_song_blobs() else {
|
||||||
|
println!("Custom song audio sweep: catalog unreadable, skipped");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut referenced: Vec<String> = Vec::new();
|
||||||
|
for blob in blobs.members() {
|
||||||
|
let Ok(song) = jzon::parse(&blob.to_string()) else {
|
||||||
|
println!("Custom song audio sweep: unparseable catalog row, skipped");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for key in ["play", "select"] {
|
||||||
|
let md5 = song["sound"][key]["md5"].as_str().unwrap_or("");
|
||||||
|
if md5.is_empty() {
|
||||||
|
println!("Custom song audio sweep: song {} has no {} cue, skipped", song["music_id"], key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
referenced.push(String::from(md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No directory means nothing was ever uploaded
|
||||||
|
let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut removed = 0;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let Some(md5) = name.strip_suffix(".ogg") else { continue; };
|
||||||
|
if md5.len() != 32 || !md5.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if referenced.iter().any(|other| other == md5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if fs::remove_file(entry.path()).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
println!("Custom song audio sweep: removed {} orphaned ogg(s)", removed);
|
||||||
|
}
|
||||||
|
drop(lock);
|
||||||
|
}
|
||||||
|
|
||||||
async fn assets(req: HttpRequest) -> HttpResponse {
|
async fn assets(req: HttpRequest) -> HttpResponse {
|
||||||
if disabled() {
|
if disabled() {
|
||||||
return HttpResponse::NotFound().finish();
|
return HttpResponse::NotFound().finish();
|
||||||
@@ -293,15 +393,20 @@ fn process_jacket(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
|
|||||||
Ok((jacket_png, blur_png))
|
Ok((jacket_png, blur_png))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue {
|
// is_loop follows the official cue convention, and it is load-bearing: the client's CriWare
|
||||||
|
// layer reports a LOOP cue as forever-playing, so Playback.IsPlayEnd() never turns true for it.
|
||||||
|
// The live's end trigger (LiveTimeController.UpdateFree: isMusicEnded -> InLiveDelay -> EndWait)
|
||||||
|
// hangs off exactly that signal, so a looping PLAY cue means the live never ends. Only the
|
||||||
|
// music-select PREVIEW cue loops, like the official select bgm.
|
||||||
|
fn cue_json(cue: &audio::Cue, cue_name: String, is_loop: bool) -> JsonValue {
|
||||||
object!{
|
object!{
|
||||||
"cue_name": cue_name,
|
"cue_name": cue_name,
|
||||||
"md5": cue.md5.clone(),
|
"md5": cue.md5.clone(),
|
||||||
"size": cue.bytes.len(),
|
"size": cue.bytes.len(),
|
||||||
"duration_sec": cue.duration_sec as f32,
|
"duration_sec": cue.duration_sec as f32,
|
||||||
"is_loop": true,
|
"is_loop": is_loop,
|
||||||
"loop_start_sec": 0.0,
|
"loop_start_sec": 0.0,
|
||||||
"loop_end_sec": cue.duration_sec as f32
|
"loop_end_sec": if is_loop { cue.duration_sec as f32 } else { 0.0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,20 +417,93 @@ fn asset_meta(bytes: &[u8]) -> (String, usize) {
|
|||||||
(format!("{:x}", md5::compute(bytes)), bytes.len())
|
(format!("{:x}", md5::compute(bytes)), bytes.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score thresholds when the uploader doesn't provide any: take the highest
|
// Score rank thresholds. These are NOT per-song in SIF2: all 637 rows of the official live
|
||||||
// difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10)
|
// masterdata carry the exact same C/B/A/S tuple, solo and multi alike (live.csv columns
|
||||||
// (~200 points per note, scaled up for harder charts), then C/B/A/S at
|
// _scoreC.._multiScoreS), because the score depends on deck strength rather than chart size.
|
||||||
// 50%/75%/100%/130% of base. Multi live thresholds are 1.2x the solo ones.
|
// Deriving them from the chart's note count instead made rank S trivial on a short custom
|
||||||
fn default_scores(full_combo: i64, level_number: i64) -> (JsonValue, JsonValue) {
|
// chart and unreachable on a long one, and skewed everything else that reads them - the live
|
||||||
let base = full_combo as f64 * 200.0 * (1.0 + level_number as f64 / 10.0);
|
// score gauge and the deck-confirm score estimation both scale off _scoreS/_multiScoreS
|
||||||
let score = |mult: f64| (base * mult) as u32;
|
// (LiveData.MaxScore = _scoreS * 5 / 4).
|
||||||
|
const OFFICIAL_SCORE: [i64; 4] = [20000, 100000, 250000, 350000];
|
||||||
|
const OFFICIAL_MULTI_SCORE: [i64; 4] = [70000, 350000, 875000, 1225000];
|
||||||
|
|
||||||
|
fn default_scores() -> (JsonValue, JsonValue) {
|
||||||
(object!{
|
(object!{
|
||||||
"c": score(0.5), "b": score(0.75), "a": score(1.0), "s": score(1.3)
|
"c": OFFICIAL_SCORE[0], "b": OFFICIAL_SCORE[1], "a": OFFICIAL_SCORE[2], "s": OFFICIAL_SCORE[3]
|
||||||
}, object!{
|
}, object!{
|
||||||
"c": score(0.5 * 1.2), "b": score(0.75 * 1.2), "a": score(1.0 * 1.2), "s": score(1.3 * 1.2)
|
"c": OFFICIAL_MULTI_SCORE[0], "b": OFFICIAL_MULTI_SCORE[1], "a": OFFICIAL_MULTI_SCORE[2], "s": OFFICIAL_MULTI_SCORE[3]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every free-text column the client shows for a song. It renders them through TMP with rich
|
||||||
|
// text on and no escaping (rich_text.rs), and the official music table carries no markup in ANY
|
||||||
|
// of these columns - <br> only ever appears in detailInfo - so no tag is allowed in any of them.
|
||||||
|
fn validate_song_text(
|
||||||
|
name: &str, name_en: &str, short_name: &str, kana: &str, artist: &str, artist_en: &str
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for (label, text) in [
|
||||||
|
("Song name", name),
|
||||||
|
("Song English name", name_en),
|
||||||
|
("Short name", short_name),
|
||||||
|
("Name reading", kana),
|
||||||
|
("Artist", artist),
|
||||||
|
("English artist", artist_en)
|
||||||
|
] {
|
||||||
|
rich_text::reject_tags(label, text, &[])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combo-mission targets. Official live_mission_combo rows are round(hardest difficulty's
|
||||||
|
// full combo * 0.2/0.4/0.6/0.8) - verified against 626 of the 637 shipped rows (the 11
|
||||||
|
// outliers are songs that gained a harder difficulty after the mission row was authored).
|
||||||
|
// The previous 25/50/75/100% spread made the fourth mission demand a literal FULL COMBO of
|
||||||
|
// the hardest chart, a target no official song ever sets.
|
||||||
|
fn mission_combo(hardest_combo: i64) -> JsonValue {
|
||||||
|
let target = |fraction: f64| (hardest_combo as f64 * fraction + 0.5) as i64;
|
||||||
|
jzon::array![target(0.2), target(0.4), target(0.6), target(0.8)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live's count-in, and the longest a marker can be in flight before its note. The count-in
|
||||||
|
// is LiveMst._startWait, 2.0 in every official live row and in ours. The flight time is
|
||||||
|
// LiveUtils.GetMarkerMoveTime(speed) = 1.725 - 0.125 * speed, clamped at 0.1, where speed is the
|
||||||
|
// player's per-difficulty rhythm-icon setting; its slowest end (and even a hypothetical 0) stays
|
||||||
|
// under the count-in, so a note at t >= 0 always has room to travel. Kept explicit so the check
|
||||||
|
// below stays honest if either constant ever moves.
|
||||||
|
const START_WAIT_SEC: f64 = 2.0;
|
||||||
|
const MAX_MARKER_MOVE_SEC: f64 = 1.725;
|
||||||
|
|
||||||
|
// A chart has to fit INSIDE its audio, at both ends.
|
||||||
|
//
|
||||||
|
// Tail: the live ends the moment the audio does - LiveTimeController's m_MusicDuration is
|
||||||
|
// LiveMst._endWait + the music length and _endWait is 0 - so a note whose MISS window closes
|
||||||
|
// after that is never judged. The player cannot full-combo the chart (the combo missions and
|
||||||
|
// the FULL COMBO banner both compare against full_combo, which counts every note), and the
|
||||||
|
// trailing markers are still on screen when the result screen takes over.
|
||||||
|
//
|
||||||
|
// Head: a marker spawns at time - GetMarkerMoveTime and the chart clock starts at -_startWait,
|
||||||
|
// so a note earlier than the flight time minus the count-in would pop in already halfway down
|
||||||
|
// the lane.
|
||||||
|
fn validate_chart_fits_audio(level: i64, chart: &JsonValue, duration_sec: f64) -> Result<(), String> {
|
||||||
|
let end = chart::end_time(chart);
|
||||||
|
if end > duration_sec {
|
||||||
|
return Err(format!(
|
||||||
|
"Difficulty {}: the chart needs {:.2}s but the audio is only {:.2}s long - the last note is never judged, because the live ends when the music does",
|
||||||
|
level, end, duration_sec
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(first) = chart::first_note_time(chart) {
|
||||||
|
let earliest = MAX_MARKER_MOVE_SEC - START_WAIT_SEC;
|
||||||
|
if first < earliest {
|
||||||
|
return Err(format!(
|
||||||
|
"Difficulty {}: the first note is at {:.2}s, before the {:.2}s the live needs to bring a marker down the lane",
|
||||||
|
level, first, earliest
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, String> {
|
fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, String> {
|
||||||
let name = field_str(fields, "name");
|
let name = field_str(fields, "name");
|
||||||
let artist = field_str(fields, "artist");
|
let artist = field_str(fields, "artist");
|
||||||
@@ -346,6 +524,15 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
return Err(format!("Unknown band category '{}'", band_category));
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_song_text(
|
||||||
|
&name,
|
||||||
|
&field_str(fields, "name_en"),
|
||||||
|
&field_str(fields, "short_name"),
|
||||||
|
&field_str(fields, "kana"),
|
||||||
|
&artist,
|
||||||
|
&field_str(fields, "artist_en")
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut visibility = field_str(fields, "visibility");
|
let mut visibility = field_str(fields, "visibility");
|
||||||
if visibility.is_empty() {
|
if visibility.is_empty() {
|
||||||
visibility = String::from("public");
|
visibility = String::from("public");
|
||||||
@@ -384,6 +571,10 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
.ok_or(String::from("An audio track is required"))?;
|
.ok_or(String::from("An audio track is required"))?;
|
||||||
let (play, select) = audio::process(audio_bytes, field_f64(fields, "preview_start_sec"), field_f64(fields, "preview_length_sec"))?;
|
let (play, select) = audio::process(audio_bytes, field_f64(fields, "preview_start_sec"), field_f64(fields, "preview_length_sec"))?;
|
||||||
|
|
||||||
|
for (level, chart, _, _, _) in charts.iter() {
|
||||||
|
validate_chart_fits_audio(*level, chart, play.duration_sec)?;
|
||||||
|
}
|
||||||
|
|
||||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
let music_id = database::next_music_id();
|
let music_id = database::next_music_id();
|
||||||
|
|
||||||
@@ -407,8 +598,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
let (jacket_md5, jacket_size) = asset_meta(&jacket);
|
let (jacket_md5, jacket_size) = asset_meta(&jacket);
|
||||||
let (jacket_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
|
let (jacket_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
|
||||||
|
|
||||||
let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap();
|
let (_, _, hardest_combo, _, _) = charts.last().unwrap();
|
||||||
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
|
let (score, multi_score) = default_scores();
|
||||||
|
|
||||||
// The upload metadata in the multipart-field schema, kept alongside the
|
// The upload metadata in the multipart-field schema, kept alongside the
|
||||||
// original artifacts so the song can be exported and re-uploaded elsewhere
|
// original artifacts so the song can be exported and re-uploaded elsewhere
|
||||||
@@ -451,8 +642,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"end_wait": 0.0,
|
"end_wait": 0.0,
|
||||||
"score": score,
|
"score": score,
|
||||||
"multi_score": multi_score,
|
"multi_score": multi_score,
|
||||||
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
|
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
|
||||||
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
|
"mission_combo": mission_combo(*hardest_combo),
|
||||||
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
||||||
"jacket_md5": jacket_md5,
|
"jacket_md5": jacket_md5,
|
||||||
"jacket_size": jacket_size,
|
"jacket_size": jacket_size,
|
||||||
@@ -462,8 +653,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"levels": levels,
|
"levels": levels,
|
||||||
"sound": {
|
"sound": {
|
||||||
"cue_sheet": format!("song_{}_{}", music_id, suffix),
|
"cue_sheet": format!("song_{}_{}", music_id, suffix),
|
||||||
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix)),
|
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix), false),
|
||||||
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix))
|
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix), true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -544,6 +735,11 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
return Err(format!("Unknown band category '{}'", band_category));
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The RESULTING text, so an edit that leaves a field alone is checked against what stays
|
||||||
|
validate_song_text(
|
||||||
|
&name, &text("name_en"), &text("short_name"), &text("kana"), &artist, &text("artist_en")
|
||||||
|
)?;
|
||||||
|
|
||||||
// (level, replacement chart json + original SIF1 bytes, full_combo, level_number)
|
// (level, replacement chart json + original SIF1 bytes, full_combo, level_number)
|
||||||
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
|
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
|
||||||
let mut removed: Vec<i64> = Vec::new();
|
let mut removed: Vec<i64> = Vec::new();
|
||||||
@@ -605,6 +801,29 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every chart in the RESULTING song has to fit the RESULTING audio, so replacing either
|
||||||
|
// side re-checks the other: new audio is validated against the charts that stay, and a new
|
||||||
|
// chart against the audio that stays (read back from the catalog's own cue metadata).
|
||||||
|
let play_duration = match &play {
|
||||||
|
Some(play) => Some(play.duration_sec),
|
||||||
|
None => old_song["sound"]["play"]["duration_sec"].as_f64()
|
||||||
|
};
|
||||||
|
if let Some(duration) = play_duration {
|
||||||
|
for (level, chart, _, _) in charts.iter() {
|
||||||
|
match chart {
|
||||||
|
Some((chart, _)) => validate_chart_fits_audio(*level, chart, duration)?,
|
||||||
|
None => {
|
||||||
|
let path = song_path(music_id, &format!("chart_{}.json", level));
|
||||||
|
if let Ok(bytes) = fs::read(&path) {
|
||||||
|
if let Ok(stored) = jzon::parse(&String::from_utf8_lossy(&bytes)) {
|
||||||
|
validate_chart_fits_audio(*level, &stored, duration)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let suffix = format!("Custom{}", music_id);
|
let suffix = format!("Custom{}", music_id);
|
||||||
let mut levels = array![];
|
let mut levels = array![];
|
||||||
for (level, chart, full_combo, level_number) in charts.iter() {
|
for (level, chart, full_combo, level_number) in charts.iter() {
|
||||||
@@ -642,8 +861,8 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
|
|
||||||
// Scores and combo missions always derive from the resulting state, with
|
// Scores and combo missions always derive from the resulting state, with
|
||||||
// the same formulas as upload
|
// the same formulas as upload
|
||||||
let (_, _, hardest_combo, hardest_stars) = charts.last().unwrap();
|
let (_, _, hardest_combo, _) = charts.last().unwrap();
|
||||||
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
|
let (score, multi_score) = default_scores();
|
||||||
|
|
||||||
let mut manifest_levels = array![];
|
let mut manifest_levels = array![];
|
||||||
for (level, _, _, level_number) in charts.iter() {
|
for (level, _, _, level_number) in charts.iter() {
|
||||||
@@ -671,10 +890,10 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
// Same id everywhere, so the cue sheet/cue names never change
|
// Same id everywhere, so the cue sheet/cue names never change
|
||||||
let mut sound = old_song["sound"].clone();
|
let mut sound = old_song["sound"].clone();
|
||||||
if let Some(play) = &play {
|
if let Some(play) = &play {
|
||||||
sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix));
|
sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix), false);
|
||||||
}
|
}
|
||||||
if let Some(select) = &select {
|
if let Some(select) = &select {
|
||||||
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix));
|
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let song = object!{
|
let song = object!{
|
||||||
@@ -693,8 +912,8 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
"end_wait": 0.0,
|
"end_wait": 0.0,
|
||||||
"score": score,
|
"score": score,
|
||||||
"multi_score": multi_score,
|
"multi_score": multi_score,
|
||||||
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
|
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
|
||||||
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
|
"mission_combo": mission_combo(*hardest_combo),
|
||||||
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
||||||
"jacket_md5": jacket_md5,
|
"jacket_md5": jacket_md5,
|
||||||
"jacket_size": jacket_size,
|
"jacket_size": jacket_size,
|
||||||
@@ -770,7 +989,6 @@ async fn upload(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("UPLOAD5");
|
|
||||||
match create_song(uid, &fields) {
|
match create_song(uid, &fields) {
|
||||||
Ok(music_id) => send_json(object!{
|
Ok(music_id) => send_json(object!{
|
||||||
result: "OK",
|
result: "OK",
|
||||||
@@ -919,11 +1137,18 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
let song = database::get_song(music_id).unwrap_or(object!{});
|
let song = database::get_song(music_id).unwrap_or(object!{});
|
||||||
|
|
||||||
|
// Same lock upload and update take, for the audio GC below: an upload
|
||||||
|
// writes its oggs before it inserts its row, so a delete that reads the
|
||||||
|
// catalog in that window sees no reference to a shared md5 and would
|
||||||
|
// unlink the file the new song is about to serve
|
||||||
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
database::delete_song(music_id);
|
database::delete_song(music_id);
|
||||||
database::bump_revision();
|
database::bump_revision();
|
||||||
// Global clear-rate stats for the dead live id (per-user score records are
|
// Global clear-rate stats for the dead live id (per-user score records are
|
||||||
// wiped lazily on each user's next userdata pull)
|
// wiped lazily on each user's next userdata pull)
|
||||||
crate::router::clear_rate::purge_live(music_id);
|
crate::router::clear_rate::purge_live(music_id);
|
||||||
|
// A custom 3D MV can't outlive the song it plays over
|
||||||
|
crate::router::custom_3dmv::purge_song(music_id);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id)));
|
let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id)));
|
||||||
// Audio is content-addressed and may be shared with another upload
|
// Audio is content-addressed and may be shared with another upload
|
||||||
@@ -933,6 +1158,7 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
|
|||||||
let _ = fs::remove_file(audio_file_path(&md5));
|
let _ = fs::remove_file(audio_file_path(&md5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drop(lock);
|
||||||
|
|
||||||
send_json(object!{
|
send_json(object!{
|
||||||
result: "OK"
|
result: "OK"
|
||||||
@@ -1014,6 +1240,344 @@ mod tests {
|
|||||||
fields.insert(String::from(key), value.as_bytes().to_vec());
|
fields.insert(String::from(key), value.as_bytes().to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a
|
||||||
|
// full 9-lane wall (the shape of the field-reported chart that exposed the old encoding)
|
||||||
|
// 4 parallel holds then a 9-wide wall. Timed to fit inside the 2s test track: uploads are
|
||||||
|
// rejected when a chart outlives its audio (validate_chart_fits_audio)
|
||||||
|
fn wall_chart() -> Vec<u8> {
|
||||||
|
let mut beatmap = jzon::array![];
|
||||||
|
for position in [2, 4, 6, 8] {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 3, "effect_value": 0.5, "position": position
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
for position in 1..=9 {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": 1.5, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 1, "effect_value": 0.0, "position": position
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
jzon::stringify(beatmap).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup migration: a chart stored with the PRE-pairing encoding (whole equal-time
|
||||||
|
// clusters sharing one num) is regrouped in place, its catalog md5/size follow the new
|
||||||
|
// bytes, the revision bumps exactly once, correctly-encoded songs stay byte-identical,
|
||||||
|
// and a second run is a complete no-op
|
||||||
|
#[test]
|
||||||
|
fn startup_migration_regroups_pre_fix_charts() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Migration Target");
|
||||||
|
field(&mut fields, "artist", "Wall Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_4", "15");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(550.0));
|
||||||
|
fields.insert(String::from("chart_4"), wall_chart());
|
||||||
|
let target = create_song(3333, &fields).unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Migration Control");
|
||||||
|
field(&mut fields, "artist", "Control Artist");
|
||||||
|
field(&mut fields, "attribute", "2");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(770.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let control = create_song(4444, &fields).unwrap();
|
||||||
|
|
||||||
|
// The upload stored the CURRENT encoding; capture it, then doctor the store back to
|
||||||
|
// the pre-pairing form exactly as an old server would have written it: squashed
|
||||||
|
// chart bytes on disk and the catalog md5/size matching those bytes
|
||||||
|
let path = song_path(target, "chart_4.json");
|
||||||
|
let fixed_bytes = fs::read(&path).unwrap();
|
||||||
|
let mut squashed = jzon::parse(&String::from_utf8_lossy(&fixed_bytes)).unwrap();
|
||||||
|
chart::squash_to_pre_fix(&mut squashed);
|
||||||
|
let squashed_bytes = jzon::stringify(squashed).into_bytes();
|
||||||
|
assert_ne!(squashed_bytes, fixed_bytes);
|
||||||
|
fs::write(&path, &squashed_bytes).unwrap();
|
||||||
|
let mut song = database::get_song(target).unwrap();
|
||||||
|
let (md5, size) = asset_meta(&squashed_bytes);
|
||||||
|
for entry in song["levels"].members_mut() {
|
||||||
|
if entry["level"] == 4 {
|
||||||
|
entry["md5"] = md5.clone().into();
|
||||||
|
entry["size"] = size.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database::update_song(target, &song);
|
||||||
|
|
||||||
|
let control_bytes = fs::read(song_path(control, "chart_1.json")).unwrap();
|
||||||
|
let control_song = database::get_song(control).unwrap();
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
// The target chart is byte-identical to what the current transcoder stores, and the
|
||||||
|
// catalog follows the new bytes
|
||||||
|
let migrated = fs::read(&path).unwrap();
|
||||||
|
assert_eq!(migrated, fixed_bytes);
|
||||||
|
let song = database::get_song(target).unwrap();
|
||||||
|
let level = song["levels"].members().find(|l| l["level"] == 4).unwrap();
|
||||||
|
let (md5, size) = asset_meta(&fixed_bytes);
|
||||||
|
assert_eq!(level["md5"].to_string(), md5);
|
||||||
|
assert_eq!(level["size"].as_usize().unwrap(), size);
|
||||||
|
// full_combo never depended on grouping and must not move
|
||||||
|
assert_eq!(level["full_combo"], 9 + 4);
|
||||||
|
|
||||||
|
// Exactly one revision bump, and the control song is untouched
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
assert_eq!(fs::read(song_path(control, "chart_1.json")).unwrap(), control_bytes);
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(control).unwrap()), jzon::stringify(control_song));
|
||||||
|
|
||||||
|
// Idempotent: a second boot changes nothing and bumps nothing
|
||||||
|
migrate::run();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), fixed_bytes);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live PLAY cue must never be a loop cue: the client reports a looping playback as
|
||||||
|
// forever-playing, and the live's end trigger waits on playback-end, so a looping play cue
|
||||||
|
// means the live never ends. New uploads emit is_loop:false, the preview cue keeps looping,
|
||||||
|
// and the startup migration un-loops catalogs written before the distinction existed.
|
||||||
|
#[test]
|
||||||
|
fn play_cue_never_loops() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Loop Check");
|
||||||
|
field(&mut fields, "artist", "Loop Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
// A tone no other test uses: the audio store is content-addressed and shared across
|
||||||
|
// tests, so a duplicate cue would keep another test's cue alive past its own GC
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(880.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let music_id = create_song(5555, &fields).unwrap();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert_eq!(song["sound"]["play"]["is_loop"], false);
|
||||||
|
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
|
||||||
|
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
||||||
|
assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0);
|
||||||
|
|
||||||
|
// Doctor the catalog back to the pre-fix shape an old server would have written,
|
||||||
|
// then boot: the migration un-loops the play cue and bumps the revision once
|
||||||
|
let mut old = song.clone();
|
||||||
|
old["sound"]["play"]["is_loop"] = true.into();
|
||||||
|
old["sound"]["play"]["loop_end_sec"] = old["sound"]["play"]["duration_sec"].clone();
|
||||||
|
database::update_song(music_id, &old);
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert_eq!(song["sound"]["play"]["is_loop"], false);
|
||||||
|
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
|
||||||
|
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
||||||
|
// The ogg bytes never moved, so the audio md5 must not change (no re-download)
|
||||||
|
assert_eq!(song["sound"]["play"]["md5"], old["sound"]["play"]["md5"]);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
|
// Idempotent
|
||||||
|
migrate::run();
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an
|
||||||
|
// artist is rejected at upload and at edit. A '<' that TMP wouldn't read as a tag survives.
|
||||||
|
#[test]
|
||||||
|
fn song_text_may_not_carry_rich_text_tags() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let base = || {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Tag Check");
|
||||||
|
field(&mut fields, "artist", "Tag Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(1210.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, label) in [
|
||||||
|
("name", "Song name"),
|
||||||
|
("name_en", "Song English name"),
|
||||||
|
("short_name", "Short name"),
|
||||||
|
("kana", "Name reading"),
|
||||||
|
("artist", "Artist"),
|
||||||
|
("artist_en", "English artist")
|
||||||
|
] {
|
||||||
|
let mut fields = base();
|
||||||
|
field(&mut fields, key, "<size=400%>boom");
|
||||||
|
let error = create_song(8888, &fields).unwrap_err();
|
||||||
|
assert!(error.contains(label), "{} -> {}", key, error);
|
||||||
|
assert!(error.contains("<size>"), "{} -> {}", key, error);
|
||||||
|
assert!(database::get_song(8888).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<3" is not a tag, so it uploads
|
||||||
|
let mut fields = base();
|
||||||
|
field(&mut fields, "name", "I <3 LIVE");
|
||||||
|
let music_id = create_song(8888, &fields).unwrap();
|
||||||
|
assert_eq!(database::get_song(music_id).unwrap()["name"], "I <3 LIVE");
|
||||||
|
|
||||||
|
// Edits are held to the same rule, and the stored song survives the rejection
|
||||||
|
let before = database::get_song(music_id).unwrap();
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "artist", "<sprite=1>");
|
||||||
|
assert!(update_song(music_id, &fields).unwrap_err().contains("Artist"));
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog the GAME reads carries the uploader's account name, which the client turns
|
||||||
|
// into the song's detail-info credit line. Account names are stored verbatim by the profile
|
||||||
|
// route, so the catalog strips rich-text tags out of them
|
||||||
|
#[test]
|
||||||
|
fn the_game_catalog_carries_a_tag_free_uploader_name() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let uid = 1;
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Credited");
|
||||||
|
field(&mut fields, "artist", "Credit Artist");
|
||||||
|
field(&mut fields, "attribute", "2");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(1320.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let music_id = create_song(uid, &fields).unwrap();
|
||||||
|
|
||||||
|
let songs = database::get_songs_for_user(uid);
|
||||||
|
let song = songs.members().find(|s| s["music_id"] == music_id).unwrap();
|
||||||
|
// The router adds the field; the stored blob never holds it
|
||||||
|
assert!(song["uploader"].is_null());
|
||||||
|
|
||||||
|
// The tag stripper is what the router applies to the account name
|
||||||
|
assert_eq!(rich_text::strip_tags("<size=400%>Nozomi"), "Nozomi");
|
||||||
|
assert_eq!(rich_text::strip_tags("Honoka"), "Honoka");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chart that outlives its audio is rejected on upload AND on edit (from either side -
|
||||||
|
// swapping in a longer chart, or shorter audio under charts that stay). The live ends when
|
||||||
|
// the music does, so those notes would never be judged. A note at t=0 is fine: the 2.0s
|
||||||
|
// count-in always covers the marker's flight time.
|
||||||
|
#[test]
|
||||||
|
fn chart_must_fit_its_audio() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
// Taps at the given times, one per lane sweep
|
||||||
|
let chart_at = |times: &[f64]| {
|
||||||
|
let mut beatmap = jzon::array![];
|
||||||
|
for (i, time) in times.iter().enumerate() {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": *time, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 1, "effect_value": 0.0, "position": (i % 9) + 1
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
jzon::stringify(beatmap).into_bytes()
|
||||||
|
};
|
||||||
|
let base = |chart: Vec<u8>| {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Fit Check");
|
||||||
|
field(&mut fields, "artist", "Fit Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
// 2 seconds of audio
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(990.0));
|
||||||
|
fields.insert(String::from("chart_1"), chart);
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2.5s note in a 2.0s track: the last note's MISS window closes long after the live ends
|
||||||
|
let error = create_song(6666, &base(chart_at(&[0.5, 2.5]))).unwrap_err();
|
||||||
|
assert!(error.contains("Difficulty 1"), "{}", error);
|
||||||
|
assert!(error.contains("the audio is only"), "{}", error);
|
||||||
|
// Nothing was stored
|
||||||
|
assert!(database::get_song(6666).is_none());
|
||||||
|
|
||||||
|
// Right at the edge: 1.8 + the 0.15 tap MISS window is 1.95, inside 2.0. A note at t=0
|
||||||
|
// is accepted too - the count-in covers the marker flight
|
||||||
|
let music_id = create_song(6666, &base(chart_at(&[0.0, 1.8]))).unwrap();
|
||||||
|
let before = database::get_song(music_id).unwrap();
|
||||||
|
|
||||||
|
// Editing in a chart that doesn't fit the stored audio is rejected, and the stored
|
||||||
|
// song is untouched
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_1"), chart_at(&[0.5, 3.0]));
|
||||||
|
let error = update_song(music_id, &fields).unwrap_err();
|
||||||
|
assert!(error.contains("the audio is only"), "{}", error);
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before.clone()));
|
||||||
|
|
||||||
|
// Adding a difficulty whose chart doesn't fit is rejected the same way
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_4"), chart_at(&[0.5, 5.0]));
|
||||||
|
field(&mut fields, "level_number_4", "12");
|
||||||
|
assert!(update_song(music_id, &fields).is_err());
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before));
|
||||||
|
|
||||||
|
// A chart that DOES fit still edits in
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_1"), chart_at(&[0.25, 1.5]));
|
||||||
|
update_song(music_id, &fields).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup migration also corrects the two fabricated masterdata values in stored
|
||||||
|
// catalogs: score-rank thresholds (official constants, not per-song) and combo missions
|
||||||
|
// (20/40/60/80% of the hardest full combo, not 25/50/75/100%)
|
||||||
|
#[test]
|
||||||
|
fn startup_migration_fixes_fabricated_scores_and_missions() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Old Values");
|
||||||
|
field(&mut fields, "artist", "Old Artist");
|
||||||
|
field(&mut fields, "attribute", "3");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(1100.0));
|
||||||
|
fields.insert(String::from("chart_1"), wall_chart());
|
||||||
|
let music_id = create_song(7777, &fields).unwrap();
|
||||||
|
|
||||||
|
let mut song = database::get_song(music_id).unwrap();
|
||||||
|
let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap();
|
||||||
|
// Doctor the catalog back to the pre-fix formulas
|
||||||
|
let base = hardest as f64 * 200.0 * (1.0 + 5.0 / 10.0);
|
||||||
|
song["score"] = object!{
|
||||||
|
"c": (base * 0.5) as u32, "b": (base * 0.75) as u32, "a": base as u32, "s": (base * 1.3) as u32
|
||||||
|
};
|
||||||
|
song["multi_score"] = object!{
|
||||||
|
"c": (base * 0.6) as u32, "b": (base * 0.9) as u32, "a": (base * 1.2) as u32, "s": (base * 1.56) as u32
|
||||||
|
};
|
||||||
|
song["mission_combo"] = jzon::array![hardest / 4, hardest / 2, hardest * 3 / 4, hardest];
|
||||||
|
database::update_song(music_id, &song);
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
let (score, multi_score) = default_scores();
|
||||||
|
assert_eq!(song["score"], score);
|
||||||
|
assert_eq!(song["multi_score"], multi_score);
|
||||||
|
assert_eq!(song["score"]["s"], 350000);
|
||||||
|
assert_eq!(song["multi_score"]["s"], 1225000);
|
||||||
|
// 20/40/60/80%, and never the full combo itself
|
||||||
|
assert_eq!(song["mission_combo"], mission_combo(hardest));
|
||||||
|
assert_eq!(song["mission_combo"][3].as_i64().unwrap(), (hardest as f64 * 0.8 + 0.5) as i64);
|
||||||
|
assert!(song["mission_combo"][3].as_i64().unwrap() < hardest);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
|
// Idempotent
|
||||||
|
migrate::run();
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Export a song, import the package as another user, and the served song
|
// Export a song, import the package as another user, and the served song
|
||||||
// must be identical apart from the assigned music_id - INCLUDING the audio
|
// must be identical apart from the assigned music_id - INCLUDING the audio
|
||||||
// md5s: ogg uploads are stored as-is and the preview encode is
|
// md5s: ogg uploads are stored as-is and the preview encode is
|
||||||
@@ -1110,16 +1674,19 @@ mod tests {
|
|||||||
assert_eq!(song["sound"]["select"]["md5"], before["sound"]["select"]["md5"]);
|
assert_eq!(song["sound"]["select"]["md5"], before["sound"]["select"]["md5"]);
|
||||||
assert_eq!(database::get_revision(), revision + 1);
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
// Adding a difficulty re-derives scores from the new hardest chart,
|
// Adding a difficulty re-derives the combo missions from the new hardest chart, the
|
||||||
// and the manifest follows so exports reflect the edited state
|
// score thresholds stay on the official constants, and the manifest follows so
|
||||||
|
// exports reflect the edited state
|
||||||
let mut fields = HashMap::new();
|
let mut fields = HashMap::new();
|
||||||
fields.insert(String::from("chart_4"), test_chart());
|
fields.insert(String::from("chart_4"), test_chart());
|
||||||
field(&mut fields, "level_number_4", "12");
|
field(&mut fields, "level_number_4", "12");
|
||||||
update_song(music_id, &fields).unwrap();
|
update_song(music_id, &fields).unwrap();
|
||||||
let song = database::get_song(music_id).unwrap();
|
let song = database::get_song(music_id).unwrap();
|
||||||
assert_eq!(song["levels"].len(), 2);
|
assert_eq!(song["levels"].len(), 2);
|
||||||
let (score, _) = default_scores(3, 12);
|
let (score, _) = default_scores();
|
||||||
assert_eq!(song["score"]["s"], score["s"]);
|
assert_eq!(song["score"]["s"], score["s"]);
|
||||||
|
let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap();
|
||||||
|
assert_eq!(song["mission_combo"], mission_combo(hardest));
|
||||||
assert!(fs::read(song_path(music_id, "chart_4.json")).is_ok());
|
assert!(fs::read(song_path(music_id, "chart_4.json")).is_ok());
|
||||||
assert!(fs::read(song_path(music_id, "original/chart_4.json")).is_ok());
|
assert!(fs::read(song_path(music_id, "original/chart_4.json")).is_ok());
|
||||||
let manifest = jzon::parse(&String::from_utf8_lossy(&fs::read(song_path(music_id, "original/manifest.json")).unwrap())).unwrap();
|
let manifest = jzon::parse(&String::from_utf8_lossy(&fs::read(song_path(music_id, "original/manifest.json")).unwrap())).unwrap();
|
||||||
@@ -1158,6 +1725,60 @@ mod tests {
|
|||||||
assert!(fs::read(audio_file_path(&before["sound"]["select"]["md5"].to_string())).is_err());
|
assert!(fs::read(audio_file_path(&before["sound"]["select"]["md5"].to_string())).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The startup sweep drops oggs nothing references any more (an upload that
|
||||||
|
// died between writing the file and storing its row), keeps every cue that
|
||||||
|
// is referenced - including one shared by two songs - and leaves anything
|
||||||
|
// that isn't a content-addressed ogg alone
|
||||||
|
#[test]
|
||||||
|
fn audio_sweep_removes_only_orphans() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Sweep Song");
|
||||||
|
field(&mut fields, "artist", "Sweep Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(311.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let music_id = create_song(4242, &fields).unwrap();
|
||||||
|
// A second song on the SAME audio: identical bytes hash to one shared file
|
||||||
|
let shared_id = create_song(4243, &fields).unwrap();
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
let play = song["sound"]["play"]["md5"].to_string();
|
||||||
|
let select = song["sound"]["select"]["md5"].to_string();
|
||||||
|
assert_eq!(database::get_song(shared_id).unwrap()["sound"]["play"]["md5"], play);
|
||||||
|
|
||||||
|
let orphan = audio_file_path(&"a1".repeat(16));
|
||||||
|
let not_an_ogg = get_data_path("custom_songs/audio/notes.txt");
|
||||||
|
let bad_name = get_data_path("custom_songs/audio/orphan.ogg");
|
||||||
|
for path in [&orphan, ¬_an_ogg, &bad_name] {
|
||||||
|
fs::write(path, b"junk").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep_audio();
|
||||||
|
|
||||||
|
assert!(fs::read(&orphan).is_err());
|
||||||
|
assert!(fs::read(audio_file_path(&play)).is_ok());
|
||||||
|
assert!(fs::read(audio_file_path(&select)).is_ok());
|
||||||
|
// Only {32 hex}.ogg is ever a sweep candidate
|
||||||
|
assert!(fs::read(¬_an_ogg).is_ok());
|
||||||
|
assert!(fs::read(&bad_name).is_ok());
|
||||||
|
|
||||||
|
// A song whose cues can't be read makes the whole sweep bail rather
|
||||||
|
// than treat that song as referencing nothing
|
||||||
|
fs::write(&orphan, b"junk").unwrap();
|
||||||
|
let shared_song = database::get_song(shared_id).unwrap();
|
||||||
|
database::update_song(shared_id, &object!{"music_id": shared_id});
|
||||||
|
sweep_audio();
|
||||||
|
assert!(fs::read(&orphan).is_ok());
|
||||||
|
assert!(fs::read(audio_file_path(&play)).is_ok());
|
||||||
|
|
||||||
|
database::update_song(shared_id, &shared_song);
|
||||||
|
let _ = fs::remove_file(&orphan);
|
||||||
|
let _ = fs::remove_file(¬_an_ogg);
|
||||||
|
let _ = fs::remove_file(&bad_name);
|
||||||
|
}
|
||||||
|
|
||||||
// mp3/wav uploads still work: symphonia decodes them and the play cue is
|
// mp3/wav uploads still work: symphonia decodes them and the play cue is
|
||||||
// transcoded to ogg-vorbis in-process
|
// transcoded to ogg-vorbis in-process
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1399,7 +2020,15 @@ mod tests {
|
|||||||
field(&mut fields, "attribute", "1");
|
field(&mut fields, "attribute", "1");
|
||||||
fields.insert(String::from("jacket"), test_png());
|
fields.insert(String::from("jacket"), test_png());
|
||||||
fields.insert(String::from("audio"), test_ogg());
|
fields.insert(String::from("audio"), test_ogg());
|
||||||
fields.insert(String::from("chart_1"), test_chart());
|
// A chart UNIQUE to this test: the self-heal assert below relies on
|
||||||
|
// the old md5 resolving nowhere, and the shared test_chart() bytes
|
||||||
|
// also live in other tests' songs (the md5 index is content-addressed
|
||||||
|
// across all songs, so identical charts alias)
|
||||||
|
fields.insert(String::from("chart_1"), jzon::stringify(jzon::array![
|
||||||
|
{"timing_sec": 0.25, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 2},
|
||||||
|
{"timing_sec": 0.75, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 6},
|
||||||
|
{"timing_sec": 1.25, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 9}
|
||||||
|
]).into_bytes());
|
||||||
let music_id = create_song(9191, &fields).unwrap();
|
let music_id = create_song(9191, &fields).unwrap();
|
||||||
|
|
||||||
let song = database::get_song(music_id).unwrap();
|
let song = database::get_song(music_id).unwrap();
|
||||||
@@ -1471,6 +2100,51 @@ mod tests {
|
|||||||
assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK);
|
assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The public clear-rate HTML page shows the REAL title for public custom
|
||||||
|
// songs (escaped - names are user input), keeps private songs entirely
|
||||||
|
// absent, and prefers name_en for the EN title attribute
|
||||||
|
#[test]
|
||||||
|
fn clearrate_html_shows_public_custom_song_titles() {
|
||||||
|
use actix_web::test::TestRequest;
|
||||||
|
use crate::router::clear_rate;
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let public_id = database::next_music_id();
|
||||||
|
database::insert_song(public_id, 6100, &object!{
|
||||||
|
music_id: public_id,
|
||||||
|
name: "Public <Song> & \"Co\"",
|
||||||
|
name_en: "Public Song EN"
|
||||||
|
}, "public", &array![], false);
|
||||||
|
let private_id = database::next_music_id();
|
||||||
|
database::insert_song(private_id, 6100, &object!{
|
||||||
|
music_id: private_id,
|
||||||
|
name: "Top Secret Anthem"
|
||||||
|
}, "private", &array![], false);
|
||||||
|
for id in [public_id, private_id] {
|
||||||
|
clear_rate::live_completed(id, 1, false, 100, 6100);
|
||||||
|
}
|
||||||
|
clear_rate::invalidate_cache();
|
||||||
|
|
||||||
|
let html = actix_web::rt::System::new().block_on(async {
|
||||||
|
let resp = clear_rate::clearrate_html(TestRequest::default().to_http_request()).await;
|
||||||
|
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
|
String::from_utf8_lossy(&body).to_string()
|
||||||
|
});
|
||||||
|
// The public song's real (escaped) title, JP cell and EN attribute
|
||||||
|
assert!(html.contains("Public <Song> & "Co""), "public title missing");
|
||||||
|
assert!(html.contains("Public Song EN"), "EN title missing");
|
||||||
|
// Never the raw unescaped markup
|
||||||
|
assert!(!html.contains("Public <Song>"), "title not escaped");
|
||||||
|
// The private song leaks neither name nor row
|
||||||
|
assert!(!html.contains("Top Secret Anthem"), "private name leaked");
|
||||||
|
|
||||||
|
// The title lookup itself only answers for public songs
|
||||||
|
assert_eq!(database::public_song_title(public_id, false), Some(String::from("Public <Song> & \"Co\"")));
|
||||||
|
assert_eq!(database::public_song_title(public_id, true), Some(String::from("Public Song EN")));
|
||||||
|
assert_eq!(database::public_song_title(private_id, false), None);
|
||||||
|
assert_eq!(database::public_song_title(private_id + 5000, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
// The JSON clear-rate endpoint filters non-public custom songs per requesting
|
// The JSON clear-rate endpoint filters non-public custom songs per requesting
|
||||||
// user: the owner sees all of theirs, a shared user sees the songs shared with
|
// user: the owner sees all of theirs, a shared user sees the songs shared with
|
||||||
// them, everyone else sees only public ones. Official (stock) live ids are
|
// them, everyone else sees only public ones. Official (stock) live ids are
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-shot line (custom-character voicelines): decode anything symphonia
|
||||||
|
// 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)?;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
let planar: Vec<&[f32]> = audio.channels.iter().map(|samples| samples.as_slice()).collect();
|
||||||
|
Ok(cue(encode(&planar, audio.sample_rate)?, duration))
|
||||||
|
}
|
||||||
|
|
||||||
// The play cue is the full track, the select cue is a preview cut with short
|
// The play cue is the full track, the select cue is a preview cut with short
|
||||||
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
|
// 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
|
// the client validates md5(file) against the value served in the catalog
|
||||||
|
|||||||
@@ -3,36 +3,170 @@ use jzon::{object, JsonValue};
|
|||||||
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
|
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
|
||||||
// into the SIF2 chart JSON the client deserializes into NoteData.
|
// into the SIF2 chart JSON the client deserializes into NoteData.
|
||||||
//
|
//
|
||||||
|
// SIF1 note effects, from the game's own LiveModel.NoteEffect
|
||||||
|
// (m_live/model/note_effect.lua): note_normal 1, note_event 2, note_hold 3, note_bomb_1 4,
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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).
|
||||||
|
//
|
||||||
// Mapping rules:
|
// Mapping rules:
|
||||||
// - line = position - 1 (both are right-to-left)
|
// - line = position - 1 (both are right-to-left)
|
||||||
// - effect 1 (and 2, the "parallel" marker) -> type 1 (tap)
|
// - effect 1 (note_normal) and 2 (note_event) -> type 1 (tap). note_event is an ordinary
|
||||||
|
// note that also fed SIF1's event scoring; it plays identically and SIF2 has no
|
||||||
|
// equivalent, so the distinction is dropped. Simultaneous hits are expressed by sharing
|
||||||
|
// a spawn num, not by the effect.
|
||||||
// - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note
|
// - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note
|
||||||
// (type 1, same line) at timing_sec + effect_value, linked through parent/child ids
|
// (type 1, same line) at timing_sec + effect_value, linked through parent/child ids
|
||||||
// - effect 4 (star/token) -> type 3
|
// - effect 4/5/6/7 (bomb_1/3/5/9, SIF1's star notes) -> type 3, SIF2's bomb note. SIF1
|
||||||
// - effect 11/12/13 (swing) and anything unknown -> plain type 1 tap for v1.
|
// varies the blast width per effect; SIF2 has one bomb with one damage value, so the
|
||||||
// Slider chains are a later feature.
|
// four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped
|
||||||
// - notes_attribute / notes_level are dropped (SIF2 has no per-note attribute)
|
// 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
|
||||||
|
// 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.
|
||||||
|
// - 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
|
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
|
||||||
// header occupies 100, real groups count up from 101, and notes that hit
|
// header occupies 100 and real groups count up from 101. The client spawns markers one
|
||||||
// simultaneously (equal timing_sec, which covers SIF1 effect 2 pairs) share one num.
|
// num-group at a time, and LiveMarkerControl.CreateMarkerUI (list overload) plain-RETURNS
|
||||||
|
// when the group holds more than 2 markers — so a num may be shared by AT MOST two notes,
|
||||||
|
// or the whole group's head markers never render (hold bands are created by the separate,
|
||||||
|
// unguarded CreateLongMarkerBandUI call, which is why 4 simultaneous holds showed trails
|
||||||
|
// with no heads). Simultaneous notes (equal final time, which covers SIF1 effect 2 pairs
|
||||||
|
// AND synthesized hold tails) are therefore sorted by lane and chunked into pairs: each
|
||||||
|
// chunk gets its own num, and every chunk after the first carries the PREVIOUS chunk's num
|
||||||
|
// in force_sync_group_id. That is exactly the official encoding — all 4292 shipped NoteData
|
||||||
|
// assets have num groups of only 1 or 2, and the 15 charts with 3-4 simultaneous notes
|
||||||
|
// (SIFAC ports, e.g. 1132_5_Sn, 1136_5_An) pair the lowest lanes under the first num and
|
||||||
|
// point the later chunk's m_ForceSyncGroupID at it. The client turns that into the extra
|
||||||
|
// connector line (LiveTimeController.CreateMarkerTimeData force-group pass matches
|
||||||
|
// 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
|
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
|
||||||
// deserializes it verbatim.
|
// deserializes it verbatim.
|
||||||
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
// - 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)
|
// line (the game counts a same-lane hold as one combo for the chain)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// bit-identical f64 - but a hold's SYNTHESIZED tail is computed (timing + effect_value), and
|
||||||
|
// e.g. 1.4 + 0.7 is 2.0999999999999996, which exact equality splits from a note literally at
|
||||||
|
// 2.1. That cost the two the shared spawn num, and with it the client's sync connector line
|
||||||
|
// (LiveTimeController.CreateMarkerTimeData pairs GroupSyncMarkerData by GroupId). The client
|
||||||
|
// itself never compares two note times - simultaneity is entirely decided here by `num`, and
|
||||||
|
// it stores time as f32 anyway - so this tolerance only has to sit above f64 accumulation
|
||||||
|
// noise (~1e-15) and below any real spacing: the tightest a chart ever uses is a 1/64 note at
|
||||||
|
// 250 BPM, ~15 ms, four orders of magnitude above this.
|
||||||
|
pub const SIMULTANEOUS_EPSILON_SEC: f64 = 1e-6;
|
||||||
|
|
||||||
|
fn simultaneous(a: f64, b: f64) -> bool {
|
||||||
|
(a - b).abs() <= SIMULTANEOUS_EPSILON_SEC
|
||||||
|
}
|
||||||
|
|
||||||
|
// MISS window, from the live_input_result masterdata BAD row (_offsetTimeSec 0.15,
|
||||||
|
// _offsetTimeSecSlider 0.34). LiveTimeController.UpdateMarkerTime destroys and force-MISSes a
|
||||||
|
// marker once the chart clock passes time + this (LiveUtils.GetMissOffsetTime), so a note is
|
||||||
|
// only judgeable while the live is still running that far past it.
|
||||||
|
const MISS_OFFSET_SEC: f64 = 0.15;
|
||||||
|
const MISS_OFFSET_SLIDER_SEC: f64 = 0.34;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
let child_id = data["child_id"].as_i64().unwrap_or(0);
|
||||||
|
if parent_id == 0 && child_id == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let line = data["line"].as_i64().unwrap_or(0);
|
||||||
|
if child_id != 0 && data["child_line"].as_i64().unwrap_or(0) != line {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
parent_id != 0 && line_of(parent_id) != Some(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chart clock time at which the LAST note stops being judgeable - i.e. the moment the live
|
||||||
|
// must still be running to. The live ends when the audio does (LiveTimeController's
|
||||||
|
// 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)
|
||||||
|
.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 mut end: f64 = 0.0;
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let offset = if is_slider(data, &line_of) { MISS_OFFSET_SLIDER_SEC } else { MISS_OFFSET_SEC };
|
||||||
|
end = end.max(data["time"].as_f64().unwrap_or(0.0) + offset);
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
// The earliest note in the chart, or None for an empty chart
|
||||||
|
pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
|
||||||
|
chart["notes"].members().skip(1)
|
||||||
|
.filter_map(|n| n["time"].as_f64())
|
||||||
|
.fold(None, |first: Option<f64>, time| Some(match first {
|
||||||
|
Some(first) => first.min(time),
|
||||||
|
None => time
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
struct WorkNote {
|
struct WorkNote {
|
||||||
time: f64,
|
time: f64,
|
||||||
line: i64,
|
line: i64,
|
||||||
kind: i64,
|
kind: i64,
|
||||||
// Index into the work list of the hold head this tail belongs to
|
// Chain links, as indices into the work list. A hold is parent -> child on the SAME line;
|
||||||
head: Option<usize>
|
// a slide is parent -> child across DIFFERENT lines (see MarkerData.IsSliderMarker).
|
||||||
|
parent: Option<usize>,
|
||||||
|
child: Option<usize>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64), String> {
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveModel.NoteEffect.isSlide
|
||||||
|
fn is_slide(effect: i64) -> bool {
|
||||||
|
effect >= 11
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIF1's star notes: note_bomb_1/3/5/9. The suffix is the blast width — star_icon.lua maps
|
||||||
|
// them to 0/1/2/4 extra lanes either side, damaging that spread when the note is missed.
|
||||||
|
// SIF2 has a single bomb note with one damage value (LiveInputResultMst._bombLifeDamage,
|
||||||
|
// applied by LiveLifeControl.CheckLife for type 3, and drawn with the star mark by
|
||||||
|
// MarkerUI), so all four map to type 3 and only the radius is lost.
|
||||||
|
fn is_bomb(effect: i64) -> bool {
|
||||||
|
(4..=7).contains(&effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64, i64), String> {
|
||||||
let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?;
|
let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?;
|
||||||
let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?;
|
let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?;
|
||||||
let effect_value = data["effect_value"].as_f64().unwrap_or(0.0);
|
let effect_value = data["effect_value"].as_f64().unwrap_or(0.0);
|
||||||
let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?;
|
let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?;
|
||||||
|
// Slide chain id. SIF1 keeps it at 1 for unchained notes; editors emit an arbitrary
|
||||||
|
// per-chain number, so it is only meaningful as "these slides belong together".
|
||||||
|
let group = data["notes_level"].as_i64().unwrap_or(1);
|
||||||
|
|
||||||
if !(1..=9).contains(&position) {
|
if !(1..=9).contains(&position) {
|
||||||
return Err(format!("Note {}: position {} is outside 1-9", index, position));
|
return Err(format!("Note {}: position {} is outside 1-9", index, position));
|
||||||
@@ -40,11 +174,11 @@ fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64)
|
|||||||
if timing < 0.0 {
|
if timing < 0.0 {
|
||||||
return Err(format!("Note {}: negative timing_sec {}", index, timing));
|
return Err(format!("Note {}: negative timing_sec {}", index, timing));
|
||||||
}
|
}
|
||||||
if effect == 3 && effect_value <= 0.0 {
|
if is_hold(effect) && effect_value <= 0.0 {
|
||||||
return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value));
|
return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((timing, effect, effect_value, position))
|
Ok((timing, effect, effect_value, position, group))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the chart JSON and its max_combo_count (== the difficulty's full_combo)
|
// Returns the chart JSON and its max_combo_count (== the difficulty's full_combo)
|
||||||
@@ -54,8 +188,10 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut work: Vec<WorkNote> = Vec::new();
|
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();
|
||||||
for (i, data) in beatmap.members().enumerate() {
|
for (i, data) in beatmap.members().enumerate() {
|
||||||
let (timing, effect, effect_value, position) = parse_sif_note(data, i)?;
|
let (timing, effect, effect_value, position, group) = parse_sif_note(data, i)?;
|
||||||
|
|
||||||
for other in beatmap.members().take(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) {
|
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
|
||||||
@@ -67,16 +203,54 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
work.push(WorkNote {
|
work.push(WorkNote {
|
||||||
time: timing,
|
time: timing,
|
||||||
line: position - 1,
|
line: position - 1,
|
||||||
kind: if effect == 4 { 3 } else { 1 },
|
kind: if is_bomb(effect) { 3 } else { 1 },
|
||||||
head: None
|
parent: None,
|
||||||
|
child: None
|
||||||
});
|
});
|
||||||
if effect == 3 {
|
// notes_level > 1 identifies the chain; 1 is SIF1's "not chained" default and must NOT be
|
||||||
|
// treated as a group, or every unchained slide in the song would link into one run
|
||||||
|
// (notes.lua guards its own grouping the same way: `if 1 < notes_level`).
|
||||||
|
if is_slide(effect) && group > 1 {
|
||||||
|
match chains.iter_mut().find(|(id, _)| *id == group) {
|
||||||
|
Some((_, members)) => members.push(head),
|
||||||
|
None => chains.push((group, vec![head]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_hold(effect) {
|
||||||
|
let tail = work.len();
|
||||||
work.push(WorkNote {
|
work.push(WorkNote {
|
||||||
time: timing + effect_value,
|
time: timing + effect_value,
|
||||||
line: position - 1,
|
line: position - 1,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
head: Some(head)
|
parent: Some(head),
|
||||||
|
child: None
|
||||||
});
|
});
|
||||||
|
work[head].child = Some(tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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() {
|
||||||
|
if members.len() < 2 {
|
||||||
|
// A lone slide cannot be a slider: SIF2 needs a cross-lane child. Leave it a tap.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ordered = members.clone();
|
||||||
|
ordered.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap());
|
||||||
|
for pair in ordered.windows(2) {
|
||||||
|
let (a, b) = (pair[0], pair[1]);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,23 +260,33 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
|
|
||||||
let mut ids = vec![0i64; work.len()];
|
let mut ids = vec![0i64; work.len()];
|
||||||
let mut nums = vec![0i64; work.len()];
|
let mut nums = vec![0i64; work.len()];
|
||||||
|
let mut force_sync = vec![0i64; work.len()];
|
||||||
let mut num = 100;
|
let mut num = 100;
|
||||||
let mut last_time = f64::NEG_INFINITY;
|
|
||||||
for (i, index) in order.iter().enumerate() {
|
for (i, index) in order.iter().enumerate() {
|
||||||
ids[*index] = (i + 1) as i64;
|
ids[*index] = (i + 1) as i64;
|
||||||
// Simultaneous notes share a spawn group
|
}
|
||||||
if work[*index].time != last_time {
|
// Spawn groups: at most TWO notes per num (see the header comment — a bigger group's head
|
||||||
|
// markers never render). A cluster of simultaneous notes is sorted by lane and chunked into
|
||||||
|
// pairs; the leftmost pair takes the first num, and each later chunk points its
|
||||||
|
// force_sync_group_id at the previous chunk's num, matching the official SIFAC-port encoding.
|
||||||
|
let mut start = 0;
|
||||||
|
while start < order.len() {
|
||||||
|
let mut end = start + 1;
|
||||||
|
while end < order.len() && simultaneous(work[order[end]].time, work[order[start]].time) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
||||||
|
cluster.sort_by_key(|index| work[*index].line);
|
||||||
|
let mut prev_num = 0;
|
||||||
|
for chunk in cluster.chunks(2) {
|
||||||
num += 1;
|
num += 1;
|
||||||
last_time = work[*index].time;
|
for index in chunk {
|
||||||
}
|
|
||||||
nums[*index] = num;
|
nums[*index] = num;
|
||||||
|
force_sync[*index] = prev_num;
|
||||||
}
|
}
|
||||||
|
prev_num = num;
|
||||||
let mut tail_of = vec![0usize; work.len()];
|
|
||||||
for (i, note) in work.iter().enumerate() {
|
|
||||||
if let Some(head) = note.head {
|
|
||||||
tail_of[head] = i;
|
|
||||||
}
|
}
|
||||||
|
start = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut notes = jzon::array![{
|
let mut notes = jzon::array![{
|
||||||
@@ -113,12 +297,12 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
let mut max_combo_count = 0;
|
let mut max_combo_count = 0;
|
||||||
for index in order.iter() {
|
for index in order.iter() {
|
||||||
let note = &work[*index];
|
let note = &work[*index];
|
||||||
let tail = tail_of[*index];
|
|
||||||
let is_head = tail != 0;
|
|
||||||
|
|
||||||
// Same-lane hold heads don't count toward the combo, their tail does
|
// NoteData.CalcMaxCombo: a note whose child is on the SAME line (a hold) does not count,
|
||||||
if !(is_head && work[tail].line == note.line) {
|
// its tail does. A cross-lane child (a slide segment) counts normally.
|
||||||
max_combo_count += 1;
|
match note.child {
|
||||||
|
Some(child) if work[child].line == note.line => {},
|
||||||
|
_ => max_combo_count += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.push(object!{
|
notes.push(object!{
|
||||||
@@ -127,11 +311,11 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
"line": note.line,
|
"line": note.line,
|
||||||
"time": note.time,
|
"time": note.time,
|
||||||
"type": note.kind,
|
"type": note.kind,
|
||||||
"parent_id": if let Some(head) = note.head { ids[head] } else { 0 },
|
"parent_id": if let Some(parent) = note.parent { ids[parent] } else { 0 },
|
||||||
"child_id": if is_head { ids[tail] } else { 0 },
|
"child_id": if let Some(child) = note.child { ids[child] } else { 0 },
|
||||||
"child_num": if is_head { nums[tail] } else { 0 },
|
"child_num": if let Some(child) = note.child { nums[child] } else { 0 },
|
||||||
"child_line": if is_head { work[tail].line } else { 0 },
|
"child_line": if let Some(child) = note.child { work[child].line } else { 0 },
|
||||||
"force_sync_group_id": 0
|
"force_sync_group_id": force_sync[*index]
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +327,113 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
}, max_combo_count))
|
}, max_combo_count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -158,6 +449,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A slide carrying its chain id; slides sharing one belong to the same run
|
||||||
|
fn sif_slide(timing_sec: f64, position: i64, effect: i64, effect_value: f64, group: i64) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
"timing_sec": timing_sec,
|
||||||
|
"notes_attribute": 1,
|
||||||
|
"notes_level": group,
|
||||||
|
"effect": effect,
|
||||||
|
"effect_value": effect_value,
|
||||||
|
"position": position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (line, type, parent_id, child_id, child_line) for each note after the dummy header
|
||||||
|
fn shape(chart: &JsonValue) -> Vec<(i64, i64, i64, i64, i64)> {
|
||||||
|
chart["notes"].members().skip(1).map(|d| (
|
||||||
|
d["line"].as_i64().unwrap(),
|
||||||
|
d["type"].as_i64().unwrap(),
|
||||||
|
d["parent_id"].as_i64().unwrap(),
|
||||||
|
d["child_id"].as_i64().unwrap(),
|
||||||
|
d["child_line"].as_i64().unwrap()
|
||||||
|
)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plain_taps() {
|
fn plain_taps() {
|
||||||
let beatmap = jzon::array![
|
let beatmap = jzon::array![
|
||||||
@@ -211,6 +525,73 @@ mod tests {
|
|||||||
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hold tail's time is COMPUTED (timing + effect_value), so it can land a few f64 ulps off
|
||||||
|
// a note written at the same beat - 1.4 + 0.7 is 2.0999999999999996, not 2.1. Exact equality
|
||||||
|
// split those two into separate spawn groups and the client lost the sync connector line
|
||||||
|
// between them; the epsilon keeps them together. The tail must still be emitted at its own
|
||||||
|
// computed time (the client stores time as f32, which lands both on 2.1 anyway)
|
||||||
|
#[test]
|
||||||
|
fn a_computed_hold_tail_shares_the_beats_spawn_group() {
|
||||||
|
assert_ne!(1.4f64 + 0.7f64, 2.1f64);
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.4, 3, 3, 0.7), // hold, tail computed at 2.0999999999999996
|
||||||
|
sif_note(2.1, 7, 1, 0.0) // tap written at 2.1
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
let tail = chart["notes"].members().find(|n| n["parent_id"] != 0).unwrap();
|
||||||
|
let tap = chart["notes"].members().find(|n| n["line"] == 6).unwrap();
|
||||||
|
assert_eq!(tail["num"], tap["num"].clone());
|
||||||
|
assert!((tail["time"].as_f64().unwrap() - 2.1).abs() < 1e-9);
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
|
||||||
|
// Genuinely distinct beats stay distinct: the epsilon is orders of magnitude below the
|
||||||
|
// tightest spacing a chart ever uses (a 1/64 note at 250 BPM is ~15 ms)
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(2.1, 3, 1, 0.0),
|
||||||
|
sif_note(2.115, 7, 1, 0.0)
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
assert_ne!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// end_time is what validate_chart_fits_audio compares against the track length: the last
|
||||||
|
// moment a note is still judgeable, using the BAD-row MISS window (0.15 tap / 0.34 slider)
|
||||||
|
#[test]
|
||||||
|
fn end_time_uses_the_miss_window_of_the_last_note() {
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 1, 0.0)]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 10.15).abs() < 1e-9);
|
||||||
|
assert_eq!(first_note_time(&chart), Some(10.0));
|
||||||
|
|
||||||
|
// A hold: the synthesized tail is the last note, and it is same-lane so not a slider
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 3, 2.0)]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 12.15).abs() < 1e-9);
|
||||||
|
assert_eq!(first_note_time(&chart), Some(10.0));
|
||||||
|
|
||||||
|
// A cross-lane slide run: every segment is a slider, so the wider window applies
|
||||||
|
let (chart, _) = transcode(&jzon::array![
|
||||||
|
sif_slide(10.0, 9, 11, 0.0, 5),
|
||||||
|
sif_slide(10.5, 8, 11, 0.0, 5)
|
||||||
|
]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 10.84).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client spawns markers one num-group at a time and CreateMarkerUI refuses lists of
|
||||||
|
// more than 2, so no num may ever be shared by 3+ notes (official charts never do)
|
||||||
|
fn assert_spawn_groups_hold_at_most_two(chart: &JsonValue) {
|
||||||
|
let mut counts: Vec<(i64, i64)> = Vec::new();
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let num = data["num"].as_i64().unwrap();
|
||||||
|
match counts.iter_mut().find(|(n, _)| *n == num) {
|
||||||
|
Some((_, c)) => *c += 1,
|
||||||
|
None => counts.push((num, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (num, count) in counts {
|
||||||
|
assert!(count <= 2, "num {} is shared by {} notes; the client renders no heads for such a group", num, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_pair() {
|
fn parallel_pair() {
|
||||||
let beatmap = jzon::array![
|
let beatmap = jzon::array![
|
||||||
@@ -224,6 +605,83 @@ mod tests {
|
|||||||
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
||||||
assert_eq!(chart["notes"][1]["type"], 1);
|
assert_eq!(chart["notes"][1]["type"], 1);
|
||||||
assert_eq!(chart["notes"][2]["type"], 1);
|
assert_eq!(chart["notes"][2]["type"], 1);
|
||||||
|
// A plain pair is the GroupSync path; the force-group field stays clear
|
||||||
|
assert_eq!(chart["notes"][1]["force_sync_group_id"], 0);
|
||||||
|
assert_eq!(chart["notes"][2]["force_sync_group_id"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn three_simultaneous_notes_split_into_pair_plus_force_synced_single() {
|
||||||
|
// Official encoding (e.g. 1132_5_Sn, 1136_5_An: the only shipped charts with 3-4
|
||||||
|
// simultaneous notes): the cluster is sorted by lane, the lowest two lanes share the
|
||||||
|
// first num, and the leftover note takes the NEXT num with force_sync_group_id pointing
|
||||||
|
// back at the pair's num. Input arrives lane-scrambled to prove the chunking sorts.
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 8, 1, 0.0),
|
||||||
|
sif_note(1.0, 2, 1, 0.0),
|
||||||
|
sif_note(1.0, 5, 1, 0.0)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(combo, 3);
|
||||||
|
assert_eq!(chart["notes"].len(), 4);
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
// Emission keeps input order on time ties; grouping is by lane
|
||||||
|
let (right, left, mid) = (&chart["notes"][1], &chart["notes"][2], &chart["notes"][3]);
|
||||||
|
assert_eq!(left["line"], 1);
|
||||||
|
assert_eq!(mid["line"], 4);
|
||||||
|
assert_eq!(right["line"], 7);
|
||||||
|
// Lanes 1 and 4 pair under the first num, force-clear
|
||||||
|
assert_eq!(left["num"], 101);
|
||||||
|
assert_eq!(mid["num"], 101);
|
||||||
|
assert_eq!(left["force_sync_group_id"], 0);
|
||||||
|
assert_eq!(mid["force_sync_group_id"], 0);
|
||||||
|
// Lane 7 rides the next num and force-syncs against the pair's num
|
||||||
|
assert_eq!(right["num"], 102);
|
||||||
|
assert_eq!(right["force_sync_group_id"], 101);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn four_simultaneous_holds_pair_heads_and_tails() {
|
||||||
|
// Second field report: 4 holds hitting together rendered their bands but no head
|
||||||
|
// markers — all four heads shared one num, and the client's CreateMarkerUI refuses
|
||||||
|
// groups over 2 while CreateLongMarkerBandUI (a separate, unguarded call) still drew
|
||||||
|
// the bands. Officially both the head cluster AND the tail cluster split 2+2 with the
|
||||||
|
// second chunk force-synced to the first (1132_5_Sn time 12.208 does this to tails).
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 3, 3, 2.0),
|
||||||
|
sif_note(1.0, 4, 3, 2.0),
|
||||||
|
sif_note(1.0, 6, 3, 2.0),
|
||||||
|
sif_note(1.0, 7, 3, 2.0)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// Same-lane hold heads don't count; the four tails do
|
||||||
|
assert_eq!(combo, 4);
|
||||||
|
assert_eq!(chart["notes"].len(), 9);
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
|
||||||
|
// Heads at 1.0: lanes 2,3 share num 101; lanes 5,6 share num 102 force-synced to 101
|
||||||
|
for (index, line, num, fs) in [(1, 2, 101, 0), (2, 3, 101, 0), (3, 5, 102, 101), (4, 6, 102, 101)] {
|
||||||
|
let head = &chart["notes"][index];
|
||||||
|
assert_eq!(head["line"], line, "head {}", index);
|
||||||
|
assert_eq!(head["num"], num, "head {}", index);
|
||||||
|
assert_eq!(head["force_sync_group_id"], fs, "head {}", index);
|
||||||
|
assert_eq!(head["parent_id"], 0, "head {}", index);
|
||||||
|
}
|
||||||
|
// Tails at 3.0: the SAME pairing applies to the synthesized cluster
|
||||||
|
for (index, line, num, fs) in [(5, 2, 103, 0), (6, 3, 103, 0), (7, 5, 104, 103), (8, 6, 104, 103)] {
|
||||||
|
let tail = &chart["notes"][index];
|
||||||
|
assert_eq!(tail["line"], line, "tail {}", index);
|
||||||
|
assert_eq!(tail["num"], num, "tail {}", index);
|
||||||
|
assert_eq!(tail["force_sync_group_id"], fs, "tail {}", index);
|
||||||
|
assert_eq!(tail["child_id"], 0, "tail {}", index);
|
||||||
|
}
|
||||||
|
// The chains still line up: each head's child_num names the tail's spawn group
|
||||||
|
assert_eq!(chart["notes"][1]["child_id"], 5);
|
||||||
|
assert_eq!(chart["notes"][1]["child_num"], 103);
|
||||||
|
assert_eq!(chart["notes"][3]["child_id"], 7);
|
||||||
|
assert_eq!(chart["notes"][3]["child_num"], 104);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -233,7 +691,7 @@ mod tests {
|
|||||||
sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5
|
sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5
|
||||||
sif_note(2.5, 7, 4, 0.0), // star
|
sif_note(2.5, 7, 4, 0.0), // star
|
||||||
sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail
|
sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail
|
||||||
sif_note(4.0, 9, 11, 0.0) // swing -> plain tap for v1
|
sif_note(4.0, 9, 11, 0.0) // lone slide, no chain partner -> stays a tap
|
||||||
];
|
];
|
||||||
let (chart, combo) = transcode(&beatmap).unwrap();
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
@@ -253,6 +711,263 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slide_chain_links_across_lanes() {
|
||||||
|
// A three-note sweep right to left, one chain. Every link must cross lanes, which is
|
||||||
|
// what MarkerData.IsSliderMarker keys on.
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_slide(1.0, 9, 11, 0.0, 500),
|
||||||
|
sif_slide(1.2, 8, 11, 0.0, 500),
|
||||||
|
sif_slide(1.4, 7, 12, 0.0, 500)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// No tails: nothing here is a hold
|
||||||
|
assert_eq!(chart["notes"].len(), 4);
|
||||||
|
// Slides are ordinary notes; the chain carries the meaning
|
||||||
|
assert_eq!(shape(&chart), vec![
|
||||||
|
(8, 1, 0, 2, 7), // root, child on line 7
|
||||||
|
(7, 1, 1, 3, 6), // middle: has parent AND child -> IsDistanceMarker
|
||||||
|
(6, 1, 2, 0, 0) // last of the run
|
||||||
|
]);
|
||||||
|
// Every link crosses lanes, so all three count for combo
|
||||||
|
assert_eq!(combo, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separate_chains_do_not_link() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_slide(1.0, 9, 11, 0.0, 500),
|
||||||
|
sif_slide(1.2, 8, 11, 0.0, 500),
|
||||||
|
sif_slide(2.0, 4, 11, 0.0, 501),
|
||||||
|
sif_slide(2.2, 3, 11, 0.0, 501)
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
assert_eq!(shape(&chart), vec![
|
||||||
|
(8, 1, 0, 2, 7),
|
||||||
|
(7, 1, 1, 0, 0), // chain 500 ends here, does not reach chain 501
|
||||||
|
(3, 1, 0, 4, 2),
|
||||||
|
(2, 1, 3, 0, 0)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_notes_level_does_not_chain() {
|
||||||
|
// notes_level 1 is SIF1's "unchained" default. Treating it as a group id would link every
|
||||||
|
// slide in the song into one run spanning the whole track.
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 9, 11, 0.0),
|
||||||
|
sif_note(1.2, 8, 11, 0.0),
|
||||||
|
sif_note(40.0, 2, 11, 0.0)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
assert_eq!(shape(&chart), vec![
|
||||||
|
(8, 1, 0, 0, 0),
|
||||||
|
(7, 1, 0, 0, 0),
|
||||||
|
(1, 1, 0, 0, 0)
|
||||||
|
]);
|
||||||
|
assert_eq!(combo, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lone_slide_stays_a_tap() {
|
||||||
|
// Nothing to chain to, and a slider needs a cross-lane child
|
||||||
|
let (chart, combo) = transcode(&jzon::array![sif_slide(1.0, 5, 11, 0.0, 500)]).unwrap();
|
||||||
|
assert_eq!(chart["notes"].len(), 2);
|
||||||
|
assert_eq!(shape(&chart), vec![(4, 1, 0, 0, 0)]);
|
||||||
|
assert_eq!(combo, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slide_chain_ends_in_a_hold() {
|
||||||
|
// A sweep terminating on effect 13: the run settles into a hold on the last lane.
|
||||||
|
// Regression: effect 13 used to lose its hold entirely and arrive as a lone note.
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_slide(1.0, 9, 11, 0.0, 500),
|
||||||
|
sif_slide(1.2, 8, 11, 0.0, 500),
|
||||||
|
sif_slide(1.4, 7, 13, 0.5, 500)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(chart["notes"].len(), 5);
|
||||||
|
assert_eq!(shape(&chart), vec![
|
||||||
|
(8, 1, 0, 2, 7), // root of the slide
|
||||||
|
(7, 1, 1, 3, 6), // middle
|
||||||
|
(6, 1, 2, 4, 6), // parent on line 7, child on line 6 -> IsSliderLongMarker
|
||||||
|
(6, 1, 3, 0, 0) // the hold tail, released normally
|
||||||
|
]);
|
||||||
|
// The tail sits at the slide-hold's time plus its duration
|
||||||
|
assert_eq!(chart["notes"][4]["time"].as_f64().unwrap(), 1.9);
|
||||||
|
// The same-lane hold head does not count; its tail does
|
||||||
|
assert_eq!(combo, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slide_hold_needs_a_duration() {
|
||||||
|
// The effect 3 duration check has to cover note_slide_hold too
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 5, 13, 0.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 5, 13, -1.0)]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_bomb_width_is_a_star_note() {
|
||||||
|
// note_bomb_1/3/5/9. SIF2 has one bomb note, so all four land on type 3; before,
|
||||||
|
// only bomb_1 did and the wider three arrived as ordinary taps.
|
||||||
|
for effect in [4, 5, 6, 7] {
|
||||||
|
let (chart, combo) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
|
||||||
|
assert_eq!(chart["notes"][1]["type"], 3, "effect {}", effect);
|
||||||
|
// Bombs are instantaneous — no tail, and they count for combo
|
||||||
|
assert_eq!(chart["notes"].len(), 2, "effect {}", effect);
|
||||||
|
assert_eq!(chart["notes"][1]["child_id"], 0, "effect {}", effect);
|
||||||
|
assert_eq!(combo, 1, "effect {}", effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_defined_effect_maps_to_a_real_note_type() {
|
||||||
|
// The whole LiveModel.NoteEffect vocabulary, and what each must become
|
||||||
|
for (effect, kind) in [(1, 1), (2, 1), (3, 1), (4, 3), (5, 3), (6, 3), (7, 3), (11, 1), (12, 1), (13, 1)] {
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 1.0)]).unwrap();
|
||||||
|
assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect);
|
||||||
|
// Whatever it is, the client must be able to resolve it
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let t = data["type"].as_i64().unwrap();
|
||||||
|
assert!((1..=3).contains(&t), "effect {} produced unresolvable type {}", effect, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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] {
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
|
||||||
|
assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// large arbitrary notes_level (38615 here) rather than SIF1's small group index, which is why
|
||||||
|
// notes_level is not used as a sync group.
|
||||||
|
#[test]
|
||||||
|
fn real_uploaded_swipe_run() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
object!{ "timing_sec": 22.0, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 9 },
|
||||||
|
object!{ "timing_sec": 22.166666666666668, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 8 },
|
||||||
|
object!{ "timing_sec": 22.333333333333332, "effect": 11, "effect_value": 2.0, "notes_attribute": 2, "notes_level": 38615, "position": 7 },
|
||||||
|
object!{ "timing_sec": 22.5, "effect": 13, "effect_value": 0.33333333333333215, "notes_attribute": 2, "notes_level": 38615, "position": 6 }
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// 4 source notes + the slide-hold's tail, plus the dummy header
|
||||||
|
assert_eq!(chart["notes"].len(), 6);
|
||||||
|
// One slider running 9 -> 8 -> 7 -> 6 (lines 8..5), the last settling into a hold
|
||||||
|
assert_eq!(shape(&chart), vec![
|
||||||
|
(8, 1, 0, 2, 7),
|
||||||
|
(7, 1, 1, 3, 6),
|
||||||
|
(6, 1, 2, 4, 5),
|
||||||
|
(5, 1, 3, 5, 5), // slide into hold
|
||||||
|
(5, 1, 4, 0, 0) // tail
|
||||||
|
]);
|
||||||
|
// The same-lane hold head is the only note that does not count
|
||||||
|
assert_eq!(combo, 4);
|
||||||
|
// Every type must be one ToMarkerType resolves; 0 would silently become MarkerType.None
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let kind = data["type"].as_i64().unwrap();
|
||||||
|
assert!((1..=3).contains(&kind), "unresolvable type {}", kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
#[test]
|
||||||
|
fn regroup_restores_pre_fix_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),
|
||||||
|
sif_note(2.75, 1, 1, 0.0), sif_note(2.75, 2, 1, 0.0), sif_note(2.75, 3, 1, 0.0),
|
||||||
|
sif_note(2.75, 4, 1, 0.0), sif_note(2.75, 5, 1, 0.0), sif_note(2.75, 6, 1, 0.0),
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
let wall: Vec<(i64, i64, i64)> = chart["notes"].members()
|
||||||
|
.filter(|d| d["time"].as_f64() == Some(2.75))
|
||||||
|
.map(|d| (d["line"].as_i64().unwrap(), d["num"].as_i64().unwrap(), d["force_sync_group_id"].as_i64().unwrap()))
|
||||||
|
.collect();
|
||||||
|
let mut wall_sorted = wall.clone();
|
||||||
|
wall_sorted.sort();
|
||||||
|
assert_eq!(wall_sorted, vec![
|
||||||
|
(0, 105, 0), (1, 105, 0),
|
||||||
|
(2, 106, 105), (3, 106, 105),
|
||||||
|
(4, 107, 106), (5, 107, 106),
|
||||||
|
(6, 108, 107), (7, 108, 107),
|
||||||
|
(8, 109, 108)
|
||||||
|
]);
|
||||||
|
// child_num follows the child's NEW num: each head's child_num names a tail group
|
||||||
|
for head in chart["notes"].members().filter(|d| d["time"].as_f64() == Some(1.0)) {
|
||||||
|
let child_id = head["child_id"].as_i64().unwrap();
|
||||||
|
let tail = chart["notes"].members().find(|d| d["id"].as_i64() == Some(child_id)).unwrap();
|
||||||
|
assert_eq!(head["child_num"], tail["num"].clone());
|
||||||
|
assert!([103, 104].contains(&tail["num"].as_i64().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn rejects_bad_charts() {
|
fn rejects_bad_charts() {
|
||||||
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
||||||
|
|||||||
121
src/router/custom_song/migrate.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,175 +49,175 @@
|
|||||||
6007,カナン,カナン,ウチーラ地区にある工房を営むメカニック。海から拾ったガラクタを使って、修理や工作をして暮らしている。,6,0,1,9901,6007,6007,162cm,-,2月10日,諏訪ななか,02D09D,02D09D,<size=100>カ<size=80>ナン,Kanan,Kanon,A mechanic who runs a workshop in the Uchira district. She gets by repairing and crafting things from junk salvaged from the sea.,-,Feb 10,Nanaka Suwa,<size=100>K<size=80>anan,223121403
|
6007,カナン,カナン,ウチーラ地区にある工房を営むメカニック。海から拾ったガラクタを使って、修理や工作をして暮らしている。,6,0,1,9901,6007,6007,162cm,-,2月10日,諏訪ななか,02D09D,02D09D,<size=100>カ<size=80>ナン,Kanan,Kanon,A mechanic who runs a workshop in the Uchira district. She gets by repairing and crafting things from junk salvaged from the sea.,-,Feb 10,Nanaka Suwa,<size=100>K<size=80>anan,223121403
|
||||||
6008,リコ,リコ,ヌマヅの外からやってきた動物学者。落ち着いた性格だが、探究心旺盛でなりふり構わないことも。,6,0,1,9901,6008,6008,160cm,-,9月19日,逢田梨香子,FB697E,FB697E,<size=100>リ<size=80>コ,Riko,Riko,"A zoologist who came from outside Numazu. Calm by nature, but so driven by curiosity that she'll throw caution to the wind.",-,Sep 19,Rikako Aida,<size=100>R<size=80>iko,223121403
|
6008,リコ,リコ,ヌマヅの外からやってきた動物学者。落ち着いた性格だが、探究心旺盛でなりふり構わないことも。,6,0,1,9901,6008,6008,160cm,-,9月19日,逢田梨香子,FB697E,FB697E,<size=100>リ<size=80>コ,Riko,Riko,"A zoologist who came from outside Numazu. Calm by nature, but so driven by curiosity that she'll throw caution to the wind.",-,Sep 19,Rikako Aida,<size=100>R<size=80>iko,223121403
|
||||||
6009,マリ,マリ,ワーシマー島にある城に住む魔王家系の末裔。あまり人前には姿を現さない。,6,0,1,9901,6009,6009,163cm,-,6月13日,鈴木愛奈,A132D9,A132D9,<size=100>マ<size=80>リ,Mari,Mari,A descendant of the Demon Lord's line who lives in a castle on Wassimer Island. She rarely shows herself in public.,-,Jun 13,Aina Suzuki,<size=100>M<size=80>ari,223121403
|
6009,マリ,マリ,ワーシマー島にある城に住む魔王家系の末裔。あまり人前には姿を現さない。,6,0,1,9901,6009,6009,163cm,-,6月13日,鈴木愛奈,A132D9,A132D9,<size=100>マ<size=80>リ,Mari,Mari,A descendant of the Demon Lord's line who lives in a castle on Wassimer Island. She rarely shows herself in public.,-,Jun 13,Aina Suzuki,<size=100>M<size=80>ari,223121403
|
||||||
5001,μ's,μ's,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5001,5001,,,?月?日,,888888,888888,μ's,μ's,μ's,Imported from School Idol Festival (2013).,,?月?日,,μ's,1
|
5001,μ's,μ's,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5001,5001,,,?月?日,,888888,888888,μ's,μ's,μ's,Imported from School Idol Festival (2013).,,?月?日,,μ's,1
|
||||||
5002,宮下ココ,ココ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5002,5002,,,6月2日,,888888,888888,宮下ココ,Coco Miyashita,Coco Miyashita,Imported from School Idol Festival (2013).,,Jun 2,,Coco Miyashita,1
|
5002,宮下ココ,ココ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5002,5002,,,6月2日,,888888,888888,宮下ココ,Coco Miyashita,Coco Miyashita,Imported from School Idol Festival (2013).,,Jun 2,,Coco Miyashita,1
|
||||||
5003,逢沢遊宇,遊宇,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5003,5003,,,7月7日,,888888,888888,逢沢遊宇,Yuu Aizawa,Yuu Aizawa,Imported from School Idol Festival (2013).,,Jul 7,,Yuu Aizawa,1
|
5003,逢沢遊宇,遊宇,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5003,5003,,,7月7日,,888888,888888,逢沢遊宇,Yuu Aizawa,Yuu Aizawa,Imported from School Idol Festival (2013).,,Jul 7,,Yuu Aizawa,1
|
||||||
5004,一之瀬マリカ,マリカ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5004,5004,,,4月20日,,888888,888888,一之瀬マリカ,Marika Ichinose,Marika Ichinose,Imported from School Idol Festival (2013).,,Apr 20,,Marika Ichinose,1
|
5004,一之瀬マリカ,マリカ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5004,5004,,,4月20日,,888888,888888,一之瀬マリカ,Marika Ichinose,Marika Ichinose,Imported from School Idol Festival (2013).,,Apr 20,,Marika Ichinose,1
|
||||||
5005,結城紗菜,紗菜,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5005,5005,,,8月20日,,888888,888888,結城紗菜,Sana Yuki,Sana Yuki,Imported from School Idol Festival (2013).,,Aug 20,,Sana Yuki,1
|
5005,結城紗菜,紗菜,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5005,5005,,,8月20日,,888888,888888,結城紗菜,Sana Yuki,Sana Yuki,Imported from School Idol Festival (2013).,,Aug 20,,Sana Yuki,1
|
||||||
5006,西村文絵,文絵,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5006,5006,,,9月20日,,888888,888888,西村文絵,Fumie Nishimura,Fumie Nishimura,Imported from School Idol Festival (2013).,,Sep 20,,Fumie Nishimura,1
|
5006,西村文絵,文絵,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5006,5006,,,9月20日,,888888,888888,西村文絵,Fumie Nishimura,Fumie Nishimura,Imported from School Idol Festival (2013).,,Sep 20,,Fumie Nishimura,1
|
||||||
5007,永山みなみ,みなみ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5007,5007,,,8月30日,,888888,888888,永山みなみ,Minami Nagayama,Minami Nagayama,Imported from School Idol Festival (2013).,,Aug 30,,Minami Nagayama,1
|
5007,永山みなみ,みなみ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5007,5007,,,8月30日,,888888,888888,永山みなみ,Minami Nagayama,Minami Nagayama,Imported from School Idol Festival (2013).,,Aug 30,,Minami Nagayama,1
|
||||||
5008,クリスティーナ,クリスティーナ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5008,5008,,,1月21日,,888888,888888,クリスティーナ,Christina,Christina,Imported from School Idol Festival (2013).,,Jan 21,,Christina,1
|
5008,クリスティーナ,クリスティーナ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5008,5008,,,1月21日,,888888,888888,クリスティーナ,Christina,Christina,Imported from School Idol Festival (2013).,,Jan 21,,Christina,1
|
||||||
5009,菊池朱美,朱美,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5009,5009,,,10月30日,,888888,888888,菊池朱美,Akemi Kikuchi,Akemi Kikuchi,Imported from School Idol Festival (2013).,,Oct 30,,Akemi Kikuchi,1
|
5009,菊池朱美,朱美,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5009,5009,,,10月30日,,888888,888888,菊池朱美,Akemi Kikuchi,Akemi Kikuchi,Imported from School Idol Festival (2013).,,Oct 30,,Akemi Kikuchi,1
|
||||||
5010,須田いるか,いるか,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5010,5010,,,2月18日,,888888,888888,須田いるか,Iruka Suda,Iruka Suda,Imported from School Idol Festival (2013).,,Feb 18,,Iruka Suda,1
|
5010,須田いるか,いるか,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5010,5010,,,2月18日,,888888,888888,須田いるか,Iruka Suda,Iruka Suda,Imported from School Idol Festival (2013).,,Feb 18,,Iruka Suda,1
|
||||||
5011,杉崎亜矢,亜矢,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5011,5011,,,9月30日,,888888,888888,杉崎亜矢,Aya Sugisaki,Aya Sugisaki,Imported from School Idol Festival (2013).,,Sep 30,,Aya Sugisaki,1
|
5011,杉崎亜矢,亜矢,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5011,5011,,,9月30日,,888888,888888,杉崎亜矢,Aya Sugisaki,Aya Sugisaki,Imported from School Idol Festival (2013).,,Sep 30,,Aya Sugisaki,1
|
||||||
5012,御堂優理,優理,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5012,5012,,,12月24日,,888888,888888,御堂優理,Yuri Mido,Yuri Mido,Imported from School Idol Festival (2013).,,Dec 24,,Yuri Mido,1
|
5012,御堂優理,優理,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5012,5012,,,12月24日,,888888,888888,御堂優理,Yuri Mido,Yuri Mido,Imported from School Idol Festival (2013).,,Dec 24,,Yuri Mido,1
|
||||||
5013,佐伯麗音,麗音,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5013,5013,,,6月16日,,888888,888888,佐伯麗音,Reine Saeki,Reine Saeki,Imported from School Idol Festival (2013).,,Jun 16,,Reine Saeki,1
|
5013,佐伯麗音,麗音,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5013,5013,,,6月16日,,888888,888888,佐伯麗音,Reine Saeki,Reine Saeki,Imported from School Idol Festival (2013).,,Jun 16,,Reine Saeki,1
|
||||||
5014,鳥居歩美,歩美,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5014,5014,,,3月8日,,888888,888888,鳥居歩美,Ayumi Torii,Ayumi Torii,Imported from School Idol Festival (2013).,,Mar 8,,Ayumi Torii,1
|
5014,鳥居歩美,歩美,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5014,5014,,,3月8日,,888888,888888,鳥居歩美,Ayumi Torii,Ayumi Torii,Imported from School Idol Festival (2013).,,Mar 8,,Ayumi Torii,1
|
||||||
5015,神谷理華,理華,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5015,5015,,,1月7日,,888888,888888,神谷理華,Rika Kamiya,Rika Kamiya,Imported from School Idol Festival (2013).,,Jan 7,,Rika Kamiya,1
|
5015,神谷理華,理華,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5015,5015,,,1月7日,,888888,888888,神谷理華,Rika Kamiya,Rika Kamiya,Imported from School Idol Festival (2013).,,Jan 7,,Rika Kamiya,1
|
||||||
5016,森嶋ななか,ななか,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5016,5016,,,3月7日,,888888,888888,森嶋ななか,Nanaka Morishima,Nanaka Morishima,Imported from School Idol Festival (2013).,,Mar 7,,Nanaka Morishima,1
|
5016,森嶋ななか,ななか,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5016,5016,,,3月7日,,888888,888888,森嶋ななか,Nanaka Morishima,Nanaka Morishima,Imported from School Idol Festival (2013).,,Mar 7,,Nanaka Morishima,1
|
||||||
5017,九条聖来,聖来,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5017,5017,,,11月15日,,888888,888888,九条聖来,Seira Kujo,Seira Kujo,Imported from School Idol Festival (2013).,,Nov 15,,Seira Kujo,1
|
5017,九条聖来,聖来,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5017,5017,,,11月15日,,888888,888888,九条聖来,Seira Kujo,Seira Kujo,Imported from School Idol Festival (2013).,,Nov 15,,Seira Kujo,1
|
||||||
5018,近江遥,遥,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5018,5018,,,11月11日,,888888,888888,近江遥,Haruka Konoe,Haruka Konoe,Imported from School Idol Festival (2013).,,Nov 11,,Haruka Konoe,1
|
5018,近江遥,遥,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5018,5018,,,11月11日,,888888,888888,近江遥,Haruka Konoe,Haruka Konoe,Imported from School Idol Festival (2013).,,Nov 11,,Haruka Konoe,1
|
||||||
5019,下園咲,咲,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5019,5019,,,5月5日,,888888,888888,下園咲,Saki Shimozono,Saki Shimozono,Imported from School Idol Festival (2013).,,May 5,,Saki Shimozono,1
|
5019,下園咲,咲,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5019,5019,,,5月5日,,888888,888888,下園咲,Saki Shimozono,Saki Shimozono,Imported from School Idol Festival (2013).,,May 5,,Saki Shimozono,1
|
||||||
5020,田中さち子,さち子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5020,5020,,,9月3日,,888888,888888,田中さち子,Sachiko Tanaka,Sachiko Tanaka,Imported from School Idol Festival (2013).,,Sep 3,,Sachiko Tanaka,1
|
5020,田中さち子,さち子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5020,5020,,,9月3日,,888888,888888,田中さち子,Sachiko Tanaka,Sachiko Tanaka,Imported from School Idol Festival (2013).,,Sep 3,,Sachiko Tanaka,1
|
||||||
5021,支倉かさね,かさね,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5021,5021,,,12月12日,,888888,888888,支倉かさね,Kasane Hasekura,Kasane Hasekura,Imported from School Idol Festival (2013).,,Dec 12,,Kasane Hasekura,1
|
5021,支倉かさね,かさね,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5021,5021,,,12月12日,,888888,888888,支倉かさね,Kasane Hasekura,Kasane Hasekura,Imported from School Idol Festival (2013).,,Dec 12,,Kasane Hasekura,1
|
||||||
5022,多々良るう,るう,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5022,5022,,,3月25日,,888888,888888,多々良るう,Ru Tatara,Ru Tatara,Imported from School Idol Festival (2013).,,Mar 25,,Ru Tatara,1
|
5022,多々良るう,るう,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5022,5022,,,3月25日,,888888,888888,多々良るう,Ru Tatara,Ru Tatara,Imported from School Idol Festival (2013).,,Mar 25,,Ru Tatara,1
|
||||||
5023,篠宮あきる,あきる,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5023,5023,,,7月20日,,888888,888888,篠宮あきる,Akiru Shinomiya,Akiru Shinomiya,Imported from School Idol Festival (2013).,,Jul 20,,Akiru Shinomiya,1
|
5023,篠宮あきる,あきる,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5023,5023,,,7月20日,,888888,888888,篠宮あきる,Akiru Shinomiya,Akiru Shinomiya,Imported from School Idol Festival (2013).,,Jul 20,,Akiru Shinomiya,1
|
||||||
5024,吉川瑞希,瑞希,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5024,5024,,,10月20日,,888888,888888,吉川瑞希,Mizuki Kikkawa,Mizuki Kikkawa,Imported from School Idol Festival (2013).,,Oct 20,,Mizuki Kikkawa,1
|
5024,吉川瑞希,瑞希,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5024,5024,,,10月20日,,888888,888888,吉川瑞希,Mizuki Kikkawa,Mizuki Kikkawa,Imported from School Idol Festival (2013).,,Oct 20,,Mizuki Kikkawa,1
|
||||||
5025,白木凪,凪,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5025,5025,,,2月9日,,888888,888888,白木凪,Nagi Shiraki,Nagi Shiraki,Imported from School Idol Festival (2013).,,Feb 9,,Nagi Shiraki,1
|
5025,白木凪,凪,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5025,5025,,,2月9日,,888888,888888,白木凪,Nagi Shiraki,Nagi Shiraki,Imported from School Idol Festival (2013).,,Feb 9,,Nagi Shiraki,1
|
||||||
5026,藤城悠弓,悠弓,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5026,5026,,,5月15日,,888888,888888,藤城悠弓,Yumi Fujishiro,Yumi Fujishiro,Imported from School Idol Festival (2013).,,May 15,,Yumi Fujishiro,1
|
5026,藤城悠弓,悠弓,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5026,5026,,,5月15日,,888888,888888,藤城悠弓,Yumi Fujishiro,Yumi Fujishiro,Imported from School Idol Festival (2013).,,May 15,,Yumi Fujishiro,1
|
||||||
5027,深山聡子,聡子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5027,5027,,,6月8日,,888888,888888,深山聡子,Satoko Miyama,Satoko Miyama,Imported from School Idol Festival (2013).,,Jun 8,,Satoko Miyama,1
|
5027,深山聡子,聡子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5027,5027,,,6月8日,,888888,888888,深山聡子,Satoko Miyama,Satoko Miyama,Imported from School Idol Festival (2013).,,Jun 8,,Satoko Miyama,1
|
||||||
5028,山内奈々子,奈々子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5028,5028,,,12月8日,,888888,888888,山内奈々子,Nanako Yamauchi,Nanako Yamauchi,Imported from School Idol Festival (2013).,,Dec 8,,Nanako Yamauchi,1
|
5028,山内奈々子,奈々子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5028,5028,,,12月8日,,888888,888888,山内奈々子,Nanako Yamauchi,Nanako Yamauchi,Imported from School Idol Festival (2013).,,Dec 8,,Nanako Yamauchi,1
|
||||||
5029,笹原京子,京子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5029,5029,,,9月8日,,888888,888888,笹原京子,Kyoko Sasahara,Kyoko Sasahara,Imported from School Idol Festival (2013).,,Sep 8,,Kyoko Sasahara,1
|
5029,笹原京子,京子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5029,5029,,,9月8日,,888888,888888,笹原京子,Kyoko Sasahara,Kyoko Sasahara,Imported from School Idol Festival (2013).,,Sep 8,,Kyoko Sasahara,1
|
||||||
5030,黒崎隼,隼,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5030,5030,,,6月15日,,888888,888888,黒崎隼,Shun Kurosaki,Shun Kurosaki,Imported from School Idol Festival (2013).,,Jun 15,,Shun Kurosaki,1
|
5030,黒崎隼,隼,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5030,5030,,,6月15日,,888888,888888,黒崎隼,Shun Kurosaki,Shun Kurosaki,Imported from School Idol Festival (2013).,,Jun 15,,Shun Kurosaki,1
|
||||||
5031,設楽ふみ,ふみ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5031,5031,,,3月20日,,888888,888888,設楽ふみ,Fumi Shitara,Fumi Shitara,Imported from School Idol Festival (2013).,,Mar 20,,Fumi Shitara,1
|
5031,設楽ふみ,ふみ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5031,5031,,,3月20日,,888888,888888,設楽ふみ,Fumi Shitara,Fumi Shitara,Imported from School Idol Festival (2013).,,Mar 20,,Fumi Shitara,1
|
||||||
5032,門田剣,剣,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5032,5032,,,7月10日,,888888,888888,門田剣,Tsurugi Kadota,Tsurugi Kadota,Imported from School Idol Festival (2013).,,Jul 10,,Tsurugi Kadota,1
|
5032,門田剣,剣,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5032,5032,,,7月10日,,888888,888888,門田剣,Tsurugi Kadota,Tsurugi Kadota,Imported from School Idol Festival (2013).,,Jul 10,,Tsurugi Kadota,1
|
||||||
5033,アルパカ[白],アルパカ[白],School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5033,5033,,,?月?日,,888888,888888,アルパカ[白],Alpaca [White],Alpaca [White],Imported from School Idol Festival (2013).,,?月?日,,Alpaca [White],1
|
5033,アルパカ[白],アルパカ[白],School Idol Festival(2013)のキャラクター。,6,0,1,9000,5033,5033,,,?月?日,,888888,888888,アルパカ[白],Alpaca [White],Alpaca [White],Imported from School Idol Festival (2013).,,?月?日,,Alpaca [White],1
|
||||||
5034,桐原優香,優香,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5034,5034,,,4月2日,,888888,888888,桐原優香,Yuuka Kirihara,Yuuka Kirihara,Imported from School Idol Festival (2013).,,Apr 2,,Yuuka Kirihara,1
|
5034,桐原優香,優香,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5034,5034,,,4月2日,,888888,888888,桐原優香,Yuuka Kirihara,Yuuka Kirihara,Imported from School Idol Festival (2013).,,Apr 2,,Yuuka Kirihara,1
|
||||||
5035,斉木風,風,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5035,5035,,,8月12日,,888888,888888,斉木風,Fuu Saiki,Fuu Saiki,Imported from School Idol Festival (2013).,,Aug 12,,Fuu Saiki,1
|
5035,斉木風,風,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5035,5035,,,8月12日,,888888,888888,斉木風,Fuu Saiki,Fuu Saiki,Imported from School Idol Festival (2013).,,Aug 12,,Fuu Saiki,1
|
||||||
5036,福原命,命,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5036,5036,,,11月5日,,888888,888888,福原命,Mikoto Fukuhara,Mikoto Fukuhara,Imported from School Idol Festival (2013).,,Nov 5,,Mikoto Fukuhara,1
|
5036,福原命,命,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5036,5036,,,11月5日,,888888,888888,福原命,Mikoto Fukuhara,Mikoto Fukuhara,Imported from School Idol Festival (2013).,,Nov 5,,Mikoto Fukuhara,1
|
||||||
5037,坂巻千鶴子,千鶴子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5037,5037,,,7月14日,,888888,888888,坂巻千鶴子,Chiduko Sakamaki,Chiduko Sakamaki,Imported from School Idol Festival (2013).,,Jul 14,,Chiduko Sakamaki,1
|
5037,坂巻千鶴子,千鶴子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5037,5037,,,7月14日,,888888,888888,坂巻千鶴子,Chiduko Sakamaki,Chiduko Sakamaki,Imported from School Idol Festival (2013).,,Jul 14,,Chiduko Sakamaki,1
|
||||||
5038,志賀仁美,仁美,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5038,5038,,,6月6日,,888888,888888,志賀仁美,Hitomi Shiga,Hitomi Shiga,Imported from School Idol Festival (2013).,,Jun 6,,Hitomi Shiga,1
|
5038,志賀仁美,仁美,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5038,5038,,,6月6日,,888888,888888,志賀仁美,Hitomi Shiga,Hitomi Shiga,Imported from School Idol Festival (2013).,,Jun 6,,Hitomi Shiga,1
|
||||||
5039,鬼崎アキラ,アキラ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5039,5039,,,8月10日,,888888,888888,鬼崎アキラ,Akira Kizaki,Akira Kizaki,Imported from School Idol Festival (2013).,,Aug 10,,Akira Kizaki,1
|
5039,鬼崎アキラ,アキラ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5039,5039,,,8月10日,,888888,888888,鬼崎アキラ,Akira Kizaki,Akira Kizaki,Imported from School Idol Festival (2013).,,Aug 10,,Akira Kizaki,1
|
||||||
5040,紫藤美咲,美咲,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5040,5040,,,4月7日,,888888,888888,紫藤美咲,Misaki Shido,Misaki Shido,Imported from School Idol Festival (2013).,,Apr 7,,Misaki Shido,1
|
5040,紫藤美咲,美咲,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5040,5040,,,4月7日,,888888,888888,紫藤美咲,Misaki Shido,Misaki Shido,Imported from School Idol Festival (2013).,,Apr 7,,Misaki Shido,1
|
||||||
5041,月島結架,結架,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5041,5041,,,7月21日,,888888,888888,月島結架,Yuka Tsukishima,Yuka Tsukishima,Imported from School Idol Festival (2013).,,Jul 21,,Yuka Tsukishima,1
|
5041,月島結架,結架,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5041,5041,,,7月21日,,888888,888888,月島結架,Yuka Tsukishima,Yuka Tsukishima,Imported from School Idol Festival (2013).,,Jul 21,,Yuka Tsukishima,1
|
||||||
5042,兵藤さゆり,さゆり,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5042,5042,,,4月22日,,888888,888888,兵藤さゆり,Sayuri Hyodo,Sayuri Hyodo,Imported from School Idol Festival (2013).,,Apr 22,,Sayuri Hyodo,1
|
5042,兵藤さゆり,さゆり,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5042,5042,,,4月22日,,888888,888888,兵藤さゆり,Sayuri Hyodo,Sayuri Hyodo,Imported from School Idol Festival (2013).,,Apr 22,,Sayuri Hyodo,1
|
||||||
5043,蘭花,蘭花,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5043,5043,,,2月8日,,888888,888888,蘭花,Ranpha,Ranpha,Imported from School Idol Festival (2013).,,Feb 8,,Ranpha,1
|
5043,蘭花,蘭花,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5043,5043,,,2月8日,,888888,888888,蘭花,Ranpha,Ranpha,Imported from School Idol Festival (2013).,,Feb 8,,Ranpha,1
|
||||||
5044,ラクシャータ,ラクシャータ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5044,5044,,,6月18日,,888888,888888,ラクシャータ,Rakshata,Rakshata,Imported from School Idol Festival (2013).,,Jun 18,,Rakshata,1
|
5044,ラクシャータ,ラクシャータ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5044,5044,,,6月18日,,888888,888888,ラクシャータ,Rakshata,Rakshata,Imported from School Idol Festival (2013).,,Jun 18,,Rakshata,1
|
||||||
5045,レベッカ,レベッカ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5045,5045,,,11月14日,,888888,888888,レベッカ,Rebecca,Rebecca,Imported from School Idol Festival (2013).,,Nov 14,,Rebecca,1
|
5045,レベッカ,レベッカ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5045,5045,,,11月14日,,888888,888888,レベッカ,Rebecca,Rebecca,Imported from School Idol Festival (2013).,,Nov 14,,Rebecca,1
|
||||||
5046,綾小路姫乃,姫乃,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5046,5046,,,4月17日,,888888,888888,綾小路姫乃,Himeno Ayanokoji,Himeno Ayanokoji,Imported from School Idol Festival (2013).,,Apr 17,,Himeno Ayanokoji,1
|
5046,綾小路姫乃,姫乃,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5046,5046,,,4月17日,,888888,888888,綾小路姫乃,Himeno Ayanokoji,Himeno Ayanokoji,Imported from School Idol Festival (2013).,,Apr 17,,Himeno Ayanokoji,1
|
||||||
5047,黒羽咲良,咲良,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5047,5047,,,4月4日,,888888,888888,黒羽咲良,Sakura Kurobane,Sakura Kurobane,Imported from School Idol Festival (2013).,,Apr 4,,Sakura Kurobane,1
|
5047,黒羽咲良,咲良,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5047,5047,,,4月4日,,888888,888888,黒羽咲良,Sakura Kurobane,Sakura Kurobane,Imported from School Idol Festival (2013).,,Apr 4,,Sakura Kurobane,1
|
||||||
5048,黒羽咲夜,咲夜,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5048,5048,,,9月9日,,888888,888888,黒羽咲夜,Sakuya Kurobane,Sakuya Kurobane,Imported from School Idol Festival (2013).,,Sep 9,,Sakuya Kurobane,1
|
5048,黒羽咲夜,咲夜,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5048,5048,,,9月9日,,888888,888888,黒羽咲夜,Sakuya Kurobane,Sakuya Kurobane,Imported from School Idol Festival (2013).,,Sep 9,,Sakuya Kurobane,1
|
||||||
5049,白瀬小雪,小雪,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5049,5049,,,2月23日,,888888,888888,白瀬小雪,Koyuki Shirase,Koyuki Shirase,Imported from School Idol Festival (2013).,,Feb 23,,Koyuki Shirase,1
|
5049,白瀬小雪,小雪,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5049,5049,,,2月23日,,888888,888888,白瀬小雪,Koyuki Shirase,Koyuki Shirase,Imported from School Idol Festival (2013).,,Feb 23,,Koyuki Shirase,1
|
||||||
5050,相川涼,涼,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5050,5050,,,5月6日,,888888,888888,相川涼,Ryo Aikawa,Ryo Aikawa,Imported from School Idol Festival (2013).,,May 6,,Ryo Aikawa,1
|
5050,相川涼,涼,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5050,5050,,,5月6日,,888888,888888,相川涼,Ryo Aikawa,Ryo Aikawa,Imported from School Idol Festival (2013).,,May 6,,Ryo Aikawa,1
|
||||||
5051,イザベラ,イザベラ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5051,5051,,,11月16日,,888888,888888,イザベラ,Isabella,Isabella,Imported from School Idol Festival (2013).,,Nov 16,,Isabella,1
|
5051,イザベラ,イザベラ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5051,5051,,,11月16日,,888888,888888,イザベラ,Isabella,Isabella,Imported from School Idol Festival (2013).,,Nov 16,,Isabella,1
|
||||||
5052,高天原睦月,睦月,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5052,5052,,,1月25日,,888888,888888,高天原睦月,Mutsuki Takamagahara,Mutsuki Takamagahara,Imported from School Idol Festival (2013).,,Jan 25,,Mutsuki Takamagahara,1
|
5052,高天原睦月,睦月,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5052,5052,,,1月25日,,888888,888888,高天原睦月,Mutsuki Takamagahara,Mutsuki Takamagahara,Imported from School Idol Festival (2013).,,Jan 25,,Mutsuki Takamagahara,1
|
||||||
5053,ジェニファー,ジェニファー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5053,5053,,,5月12日,,888888,888888,ジェニファー,Jennifer,Jennifer,Imported from School Idol Festival (2013).,,May 12,,Jennifer,1
|
5053,ジェニファー,ジェニファー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5053,5053,,,5月12日,,888888,888888,ジェニファー,Jennifer,Jennifer,Imported from School Idol Festival (2013).,,May 12,,Jennifer,1
|
||||||
5054,マリア,マリア,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5054,5054,,,8月6日,,888888,888888,マリア,Maria,Maria,Imported from School Idol Festival (2013).,,Aug 6,,Maria,1
|
5054,マリア,マリア,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5054,5054,,,8月6日,,888888,888888,マリア,Maria,Maria,Imported from School Idol Festival (2013).,,Aug 6,,Maria,1
|
||||||
5055,レオ,レオ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5055,5055,,,8月16日,,888888,888888,レオ,Leo,Leo,Imported from School Idol Festival (2013).,,Aug 16,,Leo,1
|
5055,レオ,レオ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5055,5055,,,8月16日,,888888,888888,レオ,Leo,Leo,Imported from School Idol Festival (2013).,,Aug 16,,Leo,1
|
||||||
5056,早乙女紫,紫,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5056,5056,,,3月3日,,888888,888888,早乙女紫,Yukari Saotome,Yukari Saotome,Imported from School Idol Festival (2013).,,Mar 3,,Yukari Saotome,1
|
5056,早乙女紫,紫,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5056,5056,,,3月3日,,888888,888888,早乙女紫,Yukari Saotome,Yukari Saotome,Imported from School Idol Festival (2013).,,Mar 3,,Yukari Saotome,1
|
||||||
5057,矢澤ここあ,ここあ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5057,5057,,,?月?日,,888888,888888,矢澤ここあ,Cocoa Yazawa,Cocoa Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cocoa Yazawa,1
|
5057,矢澤ここあ,ここあ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5057,5057,,,?月?日,,888888,888888,矢澤ここあ,Cocoa Yazawa,Cocoa Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cocoa Yazawa,1
|
||||||
5058,矢澤虎太郎,虎太郎,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5058,5058,,,?月?日,,888888,888888,矢澤虎太郎,Cotaro Yazawa,Cotaro Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cotaro Yazawa,1
|
5058,矢澤虎太郎,虎太郎,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5058,5058,,,?月?日,,888888,888888,矢澤虎太郎,Cotaro Yazawa,Cotaro Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cotaro Yazawa,1
|
||||||
5059,矢澤こころ,こころ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5059,5059,,,?月?日,,888888,888888,矢澤こころ,Cocoro Yazawa,Cocoro Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cocoro Yazawa,1
|
5059,矢澤こころ,こころ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5059,5059,,,?月?日,,888888,888888,矢澤こころ,Cocoro Yazawa,Cocoro Yazawa,Imported from School Idol Festival (2013).,,?月?日,,Cocoro Yazawa,1
|
||||||
5060,ミカ,ミカ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5060,5060,,,?月?日,,888888,888888,ミカ,Mika,Mika,Imported from School Idol Festival (2013).,,?月?日,,Mika,1
|
5060,ミカ,ミカ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5060,5060,,,?月?日,,888888,888888,ミカ,Mika,Mika,Imported from School Idol Festival (2013).,,?月?日,,Mika,1
|
||||||
5061,フミコ,フミコ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5061,5061,,,?月?日,,888888,888888,フミコ,Fumiko,Fumiko,Imported from School Idol Festival (2013).,,?月?日,,Fumiko,1
|
5061,フミコ,フミコ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5061,5061,,,?月?日,,888888,888888,フミコ,Fumiko,Fumiko,Imported from School Idol Festival (2013).,,?月?日,,Fumiko,1
|
||||||
5062,ヒデコ,ヒデコ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5062,5062,,,?月?日,,888888,888888,ヒデコ,Hideko,Hideko,Imported from School Idol Festival (2013).,,?月?日,,Hideko,1
|
5062,ヒデコ,ヒデコ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5062,5062,,,?月?日,,888888,888888,ヒデコ,Hideko,Hideko,Imported from School Idol Festival (2013).,,?月?日,,Hideko,1
|
||||||
5063,山田博子,博子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5063,5063,,,?月?日,,888888,888888,山田博子,Hiroko Yamada,Hiroko Yamada,Imported from School Idol Festival (2013).,,?月?日,,Hiroko Yamada,1
|
5063,山田博子,博子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5063,5063,,,?月?日,,888888,888888,山田博子,Hiroko Yamada,Hiroko Yamada,Imported from School Idol Festival (2013).,,?月?日,,Hiroko Yamada,1
|
||||||
5064,にこの母,にこの母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5064,5064,,,?月?日,,888888,888888,にこの母,Nico's mother,Nico's mother,Imported from School Idol Festival (2013).,,?月?日,,Nico's mother,1
|
5064,にこの母,にこの母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5064,5064,,,?月?日,,888888,888888,にこの母,Nico's mother,Nico's mother,Imported from School Idol Festival (2013).,,?月?日,,Nico's mother,1
|
||||||
5065,ことりの母,ことりの母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5065,5065,,,?月?日,,888888,888888,ことりの母,Kotori's mother,Kotori's mother,Imported from School Idol Festival (2013).,,?月?日,,Kotori's mother,1
|
5065,ことりの母,ことりの母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5065,5065,,,?月?日,,888888,888888,ことりの母,Kotori's mother,Kotori's mother,Imported from School Idol Festival (2013).,,?月?日,,Kotori's mother,1
|
||||||
5066,真姫の母,真姫の母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5066,5066,,,?月?日,,888888,888888,真姫の母,Maki's mother,Maki's mother,Imported from School Idol Festival (2013).,,?月?日,,Maki's mother,1
|
5066,真姫の母,真姫の母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5066,5066,,,?月?日,,888888,888888,真姫の母,Maki's mother,Maki's mother,Imported from School Idol Festival (2013).,,?月?日,,Maki's mother,1
|
||||||
5067,穂乃果の母,穂乃果の母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5067,5067,,,?月?日,,888888,888888,穂乃果の母,Honoka's mother,Honoka's mother,Imported from School Idol Festival (2013).,,?月?日,,Honoka's mother,1
|
5067,穂乃果の母,穂乃果の母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5067,5067,,,?月?日,,888888,888888,穂乃果の母,Honoka's mother,Honoka's mother,Imported from School Idol Festival (2013).,,?月?日,,Honoka's mother,1
|
||||||
5068,綺羅ツバサ,ツバサ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5068,5068,,,?月?日,,888888,888888,綺羅ツバサ,Tsubasa Kira,Tsubasa Kira,Imported from School Idol Festival (2013).,,?月?日,,Tsubasa Kira,1
|
5068,綺羅ツバサ,ツバサ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5068,5068,,,?月?日,,888888,888888,綺羅ツバサ,Tsubasa Kira,Tsubasa Kira,Imported from School Idol Festival (2013).,,?月?日,,Tsubasa Kira,1
|
||||||
5069,優木あんじゅ,あんじゅ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5069,5069,,,?月?日,,888888,888888,優木あんじゅ,Anju Yuki,Anju Yuki,Imported from School Idol Festival (2013).,,?月?日,,Anju Yuki,1
|
5069,優木あんじゅ,あんじゅ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5069,5069,,,?月?日,,888888,888888,優木あんじゅ,Anju Yuki,Anju Yuki,Imported from School Idol Festival (2013).,,?月?日,,Anju Yuki,1
|
||||||
5070,統堂英玲奈,英玲奈,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5070,5070,,,?月?日,,888888,888888,統堂英玲奈,Erena Todo,Erena Todo,Imported from School Idol Festival (2013).,,?月?日,,Erena Todo,1
|
5070,統堂英玲奈,英玲奈,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5070,5070,,,?月?日,,888888,888888,統堂英玲奈,Erena Todo,Erena Todo,Imported from School Idol Festival (2013).,,?月?日,,Erena Todo,1
|
||||||
5071,アルパカ[茶],アルパカ[茶],School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5071,5071,,,?月?日,,888888,888888,アルパカ[茶],Alpaca [Brown],Alpaca [Brown],Imported from School Idol Festival (2013).,,?月?日,,Alpaca [Brown],1
|
5071,アルパカ[茶],アルパカ[茶],School Idol Festival(2013)のキャラクター。,6,0,1,9000,5071,5071,,,?月?日,,888888,888888,アルパカ[茶],Alpaca [Brown],Alpaca [Brown],Imported from School Idol Festival (2013).,,?月?日,,Alpaca [Brown],1
|
||||||
5072,μ's,μ's,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5072,5072,,,?月?日,,888888,888888,μ's,μ's,μ's,Imported from School Idol Festival (2013).,,?月?日,,μ's,1
|
5072,μ's,μ's,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5072,5072,,,?月?日,,888888,888888,μ's,μ's,μ's,Imported from School Idol Festival (2013).,,?月?日,,μ's,1
|
||||||
5073,アルパカの子ども,アルパカの子ども,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5073,5073,,,?月?日,,888888,888888,アルパカの子ども,Alpaca's Child,Alpaca's Child,Imported from School Idol Festival (2013).,,?月?日,,Alpaca's Child,1
|
5073,アルパカの子ども,アルパカの子ども,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5073,5073,,,?月?日,,888888,888888,アルパカの子ども,Alpaca's Child,Alpaca's Child,Imported from School Idol Festival (2013).,,?月?日,,Alpaca's Child,1
|
||||||
5074,絢瀬亜里沙,亜里沙,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5074,5074,,,?月?日,,888888,888888,絢瀬亜里沙,Alisa Ayase,Alisa Ayase,Imported from School Idol Festival (2013).,,?月?日,,Alisa Ayase,1
|
5074,絢瀬亜里沙,亜里沙,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5074,5074,,,?月?日,,888888,888888,絢瀬亜里沙,Alisa Ayase,Alisa Ayase,Imported from School Idol Festival (2013).,,?月?日,,Alisa Ayase,1
|
||||||
5075,Aqours,Aqours,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5075,5075,,,?月?日,,888888,888888,Aqours,Aqours,Aqours,Imported from School Idol Festival (2013).,,?月?日,,Aqours,1
|
5075,Aqours,Aqours,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5075,5075,,,?月?日,,888888,888888,Aqours,Aqours,Aqours,Imported from School Idol Festival (2013).,,?月?日,,Aqours,1
|
||||||
5076,しいたけ,しいたけ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5076,5076,,,?月?日,,888888,888888,しいたけ,Shiitake,Shiitake,Imported from School Idol Festival (2013).,,?月?日,,Shiitake,1
|
5076,しいたけ,しいたけ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5076,5076,,,?月?日,,888888,888888,しいたけ,Shiitake,Shiitake,Imported from School Idol Festival (2013).,,?月?日,,Shiitake,1
|
||||||
5077,鹿角理亞,理亞,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5077,5077,,,12月12日,,888888,888888,鹿角理亞,Leah Kazuno,Leah Kazuno,Imported from School Idol Festival (2013).,,Dec 12,,Leah Kazuno,1
|
5077,鹿角理亞,理亞,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5077,5077,,,12月12日,,888888,888888,鹿角理亞,Leah Kazuno,Leah Kazuno,Imported from School Idol Festival (2013).,,Dec 12,,Leah Kazuno,1
|
||||||
5078,鹿角聖良,聖良,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5078,5078,,,5月4日,,888888,888888,鹿角聖良,Sarah Kazuno,Sarah Kazuno,Imported from School Idol Festival (2013).,,May 4,,Sarah Kazuno,1
|
5078,鹿角聖良,聖良,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5078,5078,,,5月4日,,888888,888888,鹿角聖良,Sarah Kazuno,Sarah Kazuno,Imported from School Idol Festival (2013).,,May 4,,Sarah Kazuno,1
|
||||||
5079,うちっちー,うちっちー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5079,5079,,,?月?日,,888888,888888,うちっちー,Uchicchi,Uchicchi,Imported from School Idol Festival (2013).,,?月?日,,Uchicchi,1
|
5079,うちっちー,うちっちー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5079,5079,,,?月?日,,888888,888888,うちっちー,Uchicchi,Uchicchi,Imported from School Idol Festival (2013).,,?月?日,,Uchicchi,1
|
||||||
5080,千歌の母,千歌の母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5080,5080,,,?月?日,,888888,888888,千歌の母,Chika's mother,Chika's mother,Imported from School Idol Festival (2013).,,?月?日,,Chika's mother,1
|
5080,千歌の母,千歌の母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5080,5080,,,?月?日,,888888,888888,千歌の母,Chika's mother,Chika's mother,Imported from School Idol Festival (2013).,,?月?日,,Chika's mother,1
|
||||||
5081,佐藤洋子,洋子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5081,5081,,,?月?日,,888888,888888,佐藤洋子,Yoko Sato,Yoko Sato,Imported from School Idol Festival (2013).,,?月?日,,Yoko Sato,1
|
5081,佐藤洋子,洋子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5081,5081,,,?月?日,,888888,888888,佐藤洋子,Yoko Sato,Yoko Sato,Imported from School Idol Festival (2013).,,?月?日,,Yoko Sato,1
|
||||||
5082,わたあめ,わたあめ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5082,5082,,,?月?日,,888888,888888,わたあめ,Wataame,Wataame,Imported from School Idol Festival (2013).,,?月?日,,Wataame,1
|
5082,わたあめ,わたあめ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5082,5082,,,?月?日,,888888,888888,わたあめ,Wataame,Wataame,Imported from School Idol Festival (2013).,,?月?日,,Wataame,1
|
||||||
5083,よしみ,よしみ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5083,5083,,,?月?日,,888888,888888,よしみ,Yoshimi,Yoshimi,Imported from School Idol Festival (2013).,,?月?日,,Yoshimi,1
|
5083,よしみ,よしみ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5083,5083,,,?月?日,,888888,888888,よしみ,Yoshimi,Yoshimi,Imported from School Idol Festival (2013).,,?月?日,,Yoshimi,1
|
||||||
5084,いつき,いつき,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5084,5084,,,?月?日,,888888,888888,いつき,Itsuki,Itsuki,Imported from School Idol Festival (2013).,,?月?日,,Itsuki,1
|
5084,いつき,いつき,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5084,5084,,,?月?日,,888888,888888,いつき,Itsuki,Itsuki,Imported from School Idol Festival (2013).,,?月?日,,Itsuki,1
|
||||||
5085,むつ,むつ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5085,5085,,,?月?日,,888888,888888,むつ,Mutsu,Mutsu,Imported from School Idol Festival (2013).,,?月?日,,Mutsu,1
|
5085,むつ,むつ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5085,5085,,,?月?日,,888888,888888,むつ,Mutsu,Mutsu,Imported from School Idol Festival (2013).,,?月?日,,Mutsu,1
|
||||||
5086,高海美渡,美渡,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5086,5086,,,?月?日,,888888,888888,高海美渡,Mito Takami,Mito Takami,Imported from School Idol Festival (2013).,,?月?日,,Mito Takami,1
|
5086,高海美渡,美渡,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5086,5086,,,?月?日,,888888,888888,高海美渡,Mito Takami,Mito Takami,Imported from School Idol Festival (2013).,,?月?日,,Mito Takami,1
|
||||||
5087,高海志満,志満,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5087,5087,,,?月?日,,888888,888888,高海志満,Shima Takami,Shima Takami,Imported from School Idol Festival (2013).,,?月?日,,Shima Takami,1
|
5087,高海志満,志満,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5087,5087,,,?月?日,,888888,888888,高海志満,Shima Takami,Shima Takami,Imported from School Idol Festival (2013).,,?月?日,,Shima Takami,1
|
||||||
5088,梨子の母,梨子の母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5088,5088,,,?月?日,,888888,888888,梨子の母,Riko's mother,Riko's mother,Imported from School Idol Festival (2013).,,?月?日,,Riko's mother,1
|
5088,梨子の母,梨子の母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5088,5088,,,?月?日,,888888,888888,梨子の母,Riko's mother,Riko's mother,Imported from School Idol Festival (2013).,,?月?日,,Riko's mother,1
|
||||||
5089,Aqours,Aqours,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5089,5089,,,?月?日,,888888,888888,Aqours,Aqours,Aqours,Imported from School Idol Festival (2013).,,?月?日,,Aqours,1
|
5089,Aqours,Aqours,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5089,5089,,,?月?日,,888888,888888,Aqours,Aqours,Aqours,Imported from School Idol Festival (2013).,,?月?日,,Aqours,1
|
||||||
5090,しいたけの子ども,しいたけの子ども,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5090,5090,,,?月?日,,888888,888888,しいたけの子ども,Shiitake's Children,Shiitake's Children,Imported from School Idol Festival (2013).,,?月?日,,Shiitake's Children,1
|
5090,しいたけの子ども,しいたけの子ども,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5090,5090,,,?月?日,,888888,888888,しいたけの子ども,Shiitake's Children,Shiitake's Children,Imported from School Idol Festival (2013).,,?月?日,,Shiitake's Children,1
|
||||||
5091,あんこ,あんこ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5091,5091,,,?月?日,,888888,888888,あんこ,Anko,Anko,Imported from School Idol Festival (2013).,,?月?日,,Anko,1
|
5091,あんこ,あんこ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5091,5091,,,?月?日,,888888,888888,あんこ,Anko,Anko,Imported from School Idol Festival (2013).,,?月?日,,Anko,1
|
||||||
5092,ブロッサム,ブロッサム,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5092,5092,,,?月?日,,888888,888888,ブロッサム,Blossom,Blossom,Imported from School Idol Festival (2013).,,?月?日,,Blossom,1
|
5092,ブロッサム,ブロッサム,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5092,5092,,,?月?日,,888888,888888,ブロッサム,Blossom,Blossom,Imported from School Idol Festival (2013).,,?月?日,,Blossom,1
|
||||||
5093,バブルス,バブルス,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5093,5093,,,?月?日,,888888,888888,バブルス,Bubbles,Bubbles,Imported from School Idol Festival (2013).,,?月?日,,Bubbles,1
|
5093,バブルス,バブルス,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5093,5093,,,?月?日,,888888,888888,バブルス,Bubbles,Bubbles,Imported from School Idol Festival (2013).,,?月?日,,Bubbles,1
|
||||||
5094,バターカップ,バターカップ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5094,5094,,,?月?日,,888888,888888,バターカップ,Buttercup,Buttercup,Imported from School Idol Festival (2013).,,?月?日,,Buttercup,1
|
5094,バターカップ,バターカップ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5094,5094,,,?月?日,,888888,888888,バターカップ,Buttercup,Buttercup,Imported from School Idol Festival (2013).,,?月?日,,Buttercup,1
|
||||||
5095,渡辺月,月,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5095,5095,,,?月?日,,888888,888888,渡辺月,Tsuki Watanabe,Tsuki Watanabe,Imported from School Idol Festival (2013).,,?月?日,,Tsuki Watanabe,1
|
5095,渡辺月,月,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5095,5095,,,?月?日,,888888,888888,渡辺月,Tsuki Watanabe,Tsuki Watanabe,Imported from School Idol Festival (2013).,,?月?日,,Tsuki Watanabe,1
|
||||||
5096,しんのすけ,しんのすけ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5096,5096,,,?月?日,,888888,888888,しんのすけ,Shinnosuke,Shinnosuke,Imported from School Idol Festival (2013).,,?月?日,,Shinnosuke,1
|
5096,しんのすけ,しんのすけ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5096,5096,,,?月?日,,888888,888888,しんのすけ,Shinnosuke,Shinnosuke,Imported from School Idol Festival (2013).,,?月?日,,Shinnosuke,1
|
||||||
5097,ひまわり,ひまわり,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5097,5097,,,?月?日,,888888,888888,ひまわり,Himawari,Himawari,Imported from School Idol Festival (2013).,,?月?日,,Himawari,1
|
5097,ひまわり,ひまわり,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5097,5097,,,?月?日,,888888,888888,ひまわり,Himawari,Himawari,Imported from School Idol Festival (2013).,,?月?日,,Himawari,1
|
||||||
5098,シロ,シロ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5098,5098,,,?月?日,,888888,888888,シロ,Shiro,Shiro,Imported from School Idol Festival (2013).,,?月?日,,Shiro,1
|
5098,シロ,シロ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5098,5098,,,?月?日,,888888,888888,シロ,Shiro,Shiro,Imported from School Idol Festival (2013).,,?月?日,,Shiro,1
|
||||||
5099,しんちゃん,しんちゃん,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5099,5099,,,?月?日,,888888,888888,しんちゃん,Shinchan,Shinchan,Imported from School Idol Festival (2013).,,?月?日,,Shinchan,1
|
5099,しんちゃん,しんちゃん,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5099,5099,,,?月?日,,888888,888888,しんちゃん,Shinchan,Shinchan,Imported from School Idol Festival (2013).,,?月?日,,Shinchan,1
|
||||||
5100,ボーちゃん,ボーちゃん,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5100,5100,,,?月?日,,888888,888888,ボーちゃん,Bo-chan,Bo-chan,Imported from School Idol Festival (2013).,,?月?日,,Bo-chan,1
|
5100,ボーちゃん,ボーちゃん,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5100,5100,,,?月?日,,888888,888888,ボーちゃん,Bo-chan,Bo-chan,Imported from School Idol Festival (2013).,,?月?日,,Bo-chan,1
|
||||||
5101,風間くん,風間くん,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5101,5101,,,?月?日,,888888,888888,風間くん,Kazama-kun,Kazama-kun,Imported from School Idol Festival (2013).,,?月?日,,Kazama-kun,1
|
5101,風間くん,風間くん,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5101,5101,,,?月?日,,888888,888888,風間くん,Kazama-kun,Kazama-kun,Imported from School Idol Festival (2013).,,?月?日,,Kazama-kun,1
|
||||||
5102,ネネちゃん,ネネちゃん,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5102,5102,,,?月?日,,888888,888888,ネネちゃん,Nene-chan,Nene-chan,Imported from School Idol Festival (2013).,,?月?日,,Nene-chan,1
|
5102,ネネちゃん,ネネちゃん,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5102,5102,,,?月?日,,888888,888888,ネネちゃん,Nene-chan,Nene-chan,Imported from School Idol Festival (2013).,,?月?日,,Nene-chan,1
|
||||||
5103,マサオくん,マサオくん,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5103,5103,,,?月?日,,888888,888888,マサオくん,Masao-kun,Masao-kun,Imported from School Idol Festival (2013).,,?月?日,,Masao-kun,1
|
5103,マサオくん,マサオくん,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5103,5103,,,?月?日,,888888,888888,マサオくん,Masao-kun,Masao-kun,Imported from School Idol Festival (2013).,,?月?日,,Masao-kun,1
|
||||||
5104,玲,玲,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5104,5104,,,?月?日,,888888,888888,玲,Lei,Lei,Imported from School Idol Festival (2013).,,?月?日,,Lei,1
|
5104,玲,玲,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5104,5104,,,?月?日,,888888,888888,玲,Lei,Lei,Imported from School Idol Festival (2013).,,?月?日,,Lei,1
|
||||||
5105,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5105,5105,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
5105,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5105,5105,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
||||||
5106,マリー,マリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5106,5106,,,?月?日,,888888,888888,マリー,Marie,Marie,Imported from School Idol Festival (2013).,,?月?日,,Marie,1
|
5106,マリー,マリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5106,5106,,,?月?日,,888888,888888,マリー,Marie,Marie,Imported from School Idol Festival (2013).,,?月?日,,Marie,1
|
||||||
5107,マーガレット,マーガレット,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5107,5107,,,?月?日,,888888,888888,マーガレット,Margaret,Margaret,Imported from School Idol Festival (2013).,,?月?日,,Margaret,1
|
5107,マーガレット,マーガレット,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5107,5107,,,?月?日,,888888,888888,マーガレット,Margaret,Margaret,Imported from School Idol Festival (2013).,,?月?日,,Margaret,1
|
||||||
5108,ひかり,ひかり,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5108,5108,,,?月?日,,888888,888888,ひかり,Hikari,Hikari,Imported from School Idol Festival (2013).,,?月?日,,Hikari,1
|
5108,ひかり,ひかり,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5108,5108,,,?月?日,,888888,888888,ひかり,Hikari,Hikari,Imported from School Idol Festival (2013).,,?月?日,,Hikari,1
|
||||||
5109,ナギ,ナギ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5109,5109,,,?月?日,,888888,888888,ナギ,Nagi,Nagi,Imported from School Idol Festival (2013).,,?月?日,,Nagi,1
|
5109,ナギ,ナギ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5109,5109,,,?月?日,,888888,888888,ナギ,Nagi,Nagi,Imported from School Idol Festival (2013).,,?月?日,,Nagi,1
|
||||||
5110,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5110,5110,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
5110,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5110,5110,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
||||||
5111,マーガレット,マーガレット,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5111,5111,,,?月?日,,888888,888888,マーガレット,Margaret,Margaret,Imported from School Idol Festival (2013).,,?月?日,,Margaret,1
|
5111,マーガレット,マーガレット,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5111,5111,,,?月?日,,888888,888888,マーガレット,Margaret,Margaret,Imported from School Idol Festival (2013).,,?月?日,,Margaret,1
|
||||||
5112,ジュスティーヌ&カロリーヌ,ジュスティーヌ&カロリーヌ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5112,5112,,,?月?日,,888888,888888,ジュスティーヌ&カロリーヌ,Justine & Caroline,Justine & Caroline,Imported from School Idol Festival (2013).,,?月?日,,Justine & Caroline,1
|
5112,ジュスティーヌ&カロリーヌ,ジュスティーヌ&カロリーヌ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5112,5112,,,?月?日,,888888,888888,ジュスティーヌ&カロリーヌ,Justine & Caroline,Justine & Caroline,Imported from School Idol Festival (2013).,,?月?日,,Justine & Caroline,1
|
||||||
5113,岳羽ゆかり,ゆかり,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5113,5113,,,?月?日,,888888,888888,岳羽ゆかり,Yukari Takeba,Yukari Takeba,Imported from School Idol Festival (2013).,,?月?日,,Yukari Takeba,1
|
5113,岳羽ゆかり,ゆかり,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5113,5113,,,?月?日,,888888,888888,岳羽ゆかり,Yukari Takeba,Yukari Takeba,Imported from School Idol Festival (2013).,,?月?日,,Yukari Takeba,1
|
||||||
5114,桐条美鶴,美鶴,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5114,5114,,,?月?日,,888888,888888,桐条美鶴,Mitsuru Kirijo,Mitsuru Kirijo,Imported from School Idol Festival (2013).,,?月?日,,Mitsuru Kirijo,1
|
5114,桐条美鶴,美鶴,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5114,5114,,,?月?日,,888888,888888,桐条美鶴,Mitsuru Kirijo,Mitsuru Kirijo,Imported from School Idol Festival (2013).,,?月?日,,Mitsuru Kirijo,1
|
||||||
5115,山岸風花,風花,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5115,5115,,,?月?日,,888888,888888,山岸風花,Fuuka Yamagishi,Fuuka Yamagishi,Imported from School Idol Festival (2013).,,?月?日,,Fuuka Yamagishi,1
|
5115,山岸風花,風花,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5115,5115,,,?月?日,,888888,888888,山岸風花,Fuuka Yamagishi,Fuuka Yamagishi,Imported from School Idol Festival (2013).,,?月?日,,Fuuka Yamagishi,1
|
||||||
5116,アイギス,アイギス,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5116,5116,,,?月?日,,888888,888888,アイギス,Aegis,Aegis,Imported from School Idol Festival (2013).,,?月?日,,Aegis,1
|
5116,アイギス,アイギス,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5116,5116,,,?月?日,,888888,888888,アイギス,Aegis,Aegis,Imported from School Idol Festival (2013).,,?月?日,,Aegis,1
|
||||||
5117,汐見琴音,琴音,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5117,5117,,,?月?日,,888888,888888,汐見琴音,Kotone Shiomi,Kotone Shiomi,Imported from School Idol Festival (2013).,,?月?日,,Kotone Shiomi,1
|
5117,汐見琴音,琴音,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5117,5117,,,?月?日,,888888,888888,汐見琴音,Kotone Shiomi,Kotone Shiomi,Imported from School Idol Festival (2013).,,?月?日,,Kotone Shiomi,1
|
||||||
5118,里中千枝,千枝,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5118,5118,,,?月?日,,888888,888888,里中千枝,Chie Satonaka,Chie Satonaka,Imported from School Idol Festival (2013).,,?月?日,,Chie Satonaka,1
|
5118,里中千枝,千枝,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5118,5118,,,?月?日,,888888,888888,里中千枝,Chie Satonaka,Chie Satonaka,Imported from School Idol Festival (2013).,,?月?日,,Chie Satonaka,1
|
||||||
5119,天城雪子,雪子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5119,5119,,,?月?日,,888888,888888,天城雪子,Yukiko Amagi,Yukiko Amagi,Imported from School Idol Festival (2013).,,?月?日,,Yukiko Amagi,1
|
5119,天城雪子,雪子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5119,5119,,,?月?日,,888888,888888,天城雪子,Yukiko Amagi,Yukiko Amagi,Imported from School Idol Festival (2013).,,?月?日,,Yukiko Amagi,1
|
||||||
5120,久慈川りせ,りせ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5120,5120,,,?月?日,,888888,888888,久慈川りせ,Rise Kujikawa,Rise Kujikawa,Imported from School Idol Festival (2013).,,?月?日,,Rise Kujikawa,1
|
5120,久慈川りせ,りせ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5120,5120,,,?月?日,,888888,888888,久慈川りせ,Rise Kujikawa,Rise Kujikawa,Imported from School Idol Festival (2013).,,?月?日,,Rise Kujikawa,1
|
||||||
5121,白鐘直斗,直斗,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5121,5121,,,?月?日,,888888,888888,白鐘直斗,Naoto Shirogane,Naoto Shirogane,Imported from School Idol Festival (2013).,,?月?日,,Naoto Shirogane,1
|
5121,白鐘直斗,直斗,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5121,5121,,,?月?日,,888888,888888,白鐘直斗,Naoto Shirogane,Naoto Shirogane,Imported from School Idol Festival (2013).,,?月?日,,Naoto Shirogane,1
|
||||||
5122,マリー,マリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5122,5122,,,?月?日,,888888,888888,マリー,Marie,Marie,Imported from School Idol Festival (2013).,,?月?日,,Marie,1
|
5122,マリー,マリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5122,5122,,,?月?日,,888888,888888,マリー,Marie,Marie,Imported from School Idol Festival (2013).,,?月?日,,Marie,1
|
||||||
5123,パンサー,パンサー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5123,5123,,,?月?日,,888888,888888,パンサー,Panther,Panther,Imported from School Idol Festival (2013).,,?月?日,,Panther,1
|
5123,パンサー,パンサー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5123,5123,,,?月?日,,888888,888888,パンサー,Panther,Panther,Imported from School Idol Festival (2013).,,?月?日,,Panther,1
|
||||||
5124,クイーン,クイーン,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5124,5124,,,?月?日,,888888,888888,クイーン,Queen,Queen,Imported from School Idol Festival (2013).,,?月?日,,Queen,1
|
5124,クイーン,クイーン,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5124,5124,,,?月?日,,888888,888888,クイーン,Queen,Queen,Imported from School Idol Festival (2013).,,?月?日,,Queen,1
|
||||||
5125,ナビ,ナビ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5125,5125,,,?月?日,,888888,888888,ナビ,Oracle,Oracle,Imported from School Idol Festival (2013).,,?月?日,,Oracle,1
|
5125,ナビ,ナビ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5125,5125,,,?月?日,,888888,888888,ナビ,Oracle,Oracle,Imported from School Idol Festival (2013).,,?月?日,,Oracle,1
|
||||||
5126,ノワール,ノワール,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5126,5126,,,?月?日,,888888,888888,ノワール,Noir,Noir,Imported from School Idol Festival (2013).,,?月?日,,Noir,1
|
5126,ノワール,ノワール,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5126,5126,,,?月?日,,888888,888888,ノワール,Noir,Noir,Imported from School Idol Festival (2013).,,?月?日,,Noir,1
|
||||||
5127,ヴァイオレット,ヴァイオレット,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5127,5127,,,?月?日,,888888,888888,ヴァイオレット,Violet,Violet,Imported from School Idol Festival (2013).,,?月?日,,Violet,1
|
5127,ヴァイオレット,ヴァイオレット,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5127,5127,,,?月?日,,888888,888888,ヴァイオレット,Violet,Violet,Imported from School Idol Festival (2013).,,?月?日,,Violet,1
|
||||||
5128,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5128,5128,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
5128,エリザベス,エリザベス,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5128,5128,,,?月?日,,888888,888888,エリザベス,Elizabeth,Elizabeth,Imported from School Idol Festival (2013).,,?月?日,,Elizabeth,1
|
||||||
5129,カレン,カレン,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5129,5129,,,?月?日,,888888,888888,カレン,Kallen,Kallen,Imported from School Idol Festival (2013).,,?月?日,,Kallen,1
|
5129,カレン,カレン,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5129,5129,,,?月?日,,888888,888888,カレン,Kallen,Kallen,Imported from School Idol Festival (2013).,,?月?日,,Kallen,1
|
||||||
5130,シャーリー,シャーリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5130,5130,,,?月?日,,888888,888888,シャーリー,Shirley,Shirley,Imported from School Idol Festival (2013).,,?月?日,,Shirley,1
|
5130,シャーリー,シャーリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5130,5130,,,?月?日,,888888,888888,シャーリー,Shirley,Shirley,Imported from School Idol Festival (2013).,,?月?日,,Shirley,1
|
||||||
5131,C.C.,C.C.,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5131,5131,,,?月?日,,888888,888888,C.C.,C.C.,C.C.,Imported from School Idol Festival (2013).,,?月?日,,C.C.,1
|
5131,C.C.,C.C.,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5131,5131,,,?月?日,,888888,888888,C.C.,C.C.,C.C.,Imported from School Idol Festival (2013).,,?月?日,,C.C.,1
|
||||||
5132,コーネリア,コーネリア,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5132,5132,,,?月?日,,888888,888888,コーネリア,Cornelia,Cornelia,Imported from School Idol Festival (2013).,,?月?日,,Cornelia,1
|
5132,コーネリア,コーネリア,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5132,5132,,,?月?日,,888888,888888,コーネリア,Cornelia,Cornelia,Imported from School Idol Festival (2013).,,?月?日,,Cornelia,1
|
||||||
5133,ユーフェミア,ユーフェミア,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5133,5133,,,?月?日,,888888,888888,ユーフェミア,Euphemia,Euphemia,Imported from School Idol Festival (2013).,,?月?日,,Euphemia,1
|
5133,ユーフェミア,ユーフェミア,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5133,5133,,,?月?日,,888888,888888,ユーフェミア,Euphemia,Euphemia,Imported from School Idol Festival (2013).,,?月?日,,Euphemia,1
|
||||||
5134,ナナリー,ナナリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5134,5134,,,?月?日,,888888,888888,ナナリー,Nunnally,Nunnally,Imported from School Idol Festival (2013).,,?月?日,,Nunnally,1
|
5134,ナナリー,ナナリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5134,5134,,,?月?日,,888888,888888,ナナリー,Nunnally,Nunnally,Imported from School Idol Festival (2013).,,?月?日,,Nunnally,1
|
||||||
5135,ヴィレッタ,ヴィレッタ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5135,5135,,,?月?日,,888888,888888,ヴィレッタ,Villetta,Villetta,Imported from School Idol Festival (2013).,,?月?日,,Villetta,1
|
5135,ヴィレッタ,ヴィレッタ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5135,5135,,,?月?日,,888888,888888,ヴィレッタ,Villetta,Villetta,Imported from School Idol Festival (2013).,,?月?日,,Villetta,1
|
||||||
5136,咲世子,咲世子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5136,5136,,,?月?日,,888888,888888,咲世子,Sayoko,Sayoko,Imported from School Idol Festival (2013).,,?月?日,,Sayoko,1
|
5136,咲世子,咲世子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5136,5136,,,?月?日,,888888,888888,咲世子,Sayoko,Sayoko,Imported from School Idol Festival (2013).,,?月?日,,Sayoko,1
|
||||||
5137,アーニャ,アーニャ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5137,5137,,,?月?日,,888888,888888,アーニャ,Anya,Anya,Imported from School Idol Festival (2013).,,?月?日,,Anya,1
|
5137,アーニャ,アーニャ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5137,5137,,,?月?日,,888888,888888,アーニャ,Anya,Anya,Imported from School Idol Festival (2013).,,?月?日,,Anya,1
|
||||||
5138,川本美里,美里,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5138,5138,,,?月?日,,888888,888888,川本美里,Misato Kawamoto,Misato Kawamoto,Imported from School Idol Festival (2013).,,?月?日,,Misato Kawamoto,1
|
5138,川本美里,美里,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5138,5138,,,?月?日,,888888,888888,川本美里,Misato Kawamoto,Misato Kawamoto,Imported from School Idol Festival (2013).,,?月?日,,Misato Kawamoto,1
|
||||||
5139,夏川マイ,マイ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5139,5139,,,?月?日,,888888,888888,夏川マイ,Mai Natsukawa,Mai Natsukawa,Imported from School Idol Festival (2013).,,?月?日,,Mai Natsukawa,1
|
5139,夏川マイ,マイ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5139,5139,,,?月?日,,888888,888888,夏川マイ,Mai Natsukawa,Mai Natsukawa,Imported from School Idol Festival (2013).,,?月?日,,Mai Natsukawa,1
|
||||||
5140,聖澤悠奈,悠奈,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5140,5140,,,?月?日,,888888,888888,聖澤悠奈,Yuuna Hijirisawa,Yuuna Hijirisawa,Imported from School Idol Festival (2013).,,?月?日,,Yuuna Hijirisawa,1
|
5140,聖澤悠奈,悠奈,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5140,5140,,,?月?日,,888888,888888,聖澤悠奈,Yuuna Hijirisawa,Yuuna Hijirisawa,Imported from School Idol Festival (2013).,,?月?日,,Yuuna Hijirisawa,1
|
||||||
5141,柊摩央,摩央,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5141,5141,,,?月?日,,888888,888888,柊摩央,Mao Hiiragi,Mao Hiiragi,Imported from School Idol Festival (2013).,,?月?日,,Mao Hiiragi,1
|
5141,柊摩央,摩央,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5141,5141,,,?月?日,,888888,888888,柊摩央,Mao Hiiragi,Mao Hiiragi,Imported from School Idol Festival (2013).,,?月?日,,Mao Hiiragi,1
|
||||||
5142,さや,さや,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5142,5142,,,?月?日,,888888,888888,さや,Saya,Saya,Imported from School Idol Festival (2013).,,?月?日,,Saya,1
|
5142,さや,さや,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5142,5142,,,?月?日,,888888,888888,さや,Saya,Saya,Imported from School Idol Festival (2013).,,?月?日,,Saya,1
|
||||||
5143,ナナミ,ナナミ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5143,5143,,,?月?日,,888888,888888,ナナミ,Nanami,Nanami,Imported from School Idol Festival (2013).,,?月?日,,Nanami,1
|
5143,ナナミ,ナナミ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5143,5143,,,?月?日,,888888,888888,ナナミ,Nanami,Nanami,Imported from School Idol Festival (2013).,,?月?日,,Nanami,1
|
||||||
5144,ヤエ,ヤエ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5144,5144,,,?月?日,,888888,888888,ヤエ,Yae,Yae,Imported from School Idol Festival (2013).,,?月?日,,Yae,1
|
5144,ヤエ,ヤエ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5144,5144,,,?月?日,,888888,888888,ヤエ,Yae,Yae,Imported from School Idol Festival (2013).,,?月?日,,Yae,1
|
||||||
5145,ココノ,ココノ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5145,5145,,,?月?日,,888888,888888,ココノ,Kokono,Kokono,Imported from School Idol Festival (2013).,,?月?日,,Kokono,1
|
5145,ココノ,ココノ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5145,5145,,,?月?日,,888888,888888,ココノ,Kokono,Kokono,Imported from School Idol Festival (2013).,,?月?日,,Kokono,1
|
||||||
5146,理事長,理事長,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5146,5146,,,?月?日,,888888,888888,理事長,Director,Director,Imported from School Idol Festival (2013).,,?月?日,,Director,1
|
5146,理事長,理事長,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5146,5146,,,?月?日,,888888,888888,理事長,Director,Director,Imported from School Idol Festival (2013).,,?月?日,,Director,1
|
||||||
5147,澁谷ありあ,ありあ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5147,5147,,,?月?日,,888888,888888,澁谷ありあ,Aria Shibuya,Aria Shibuya,Imported from School Idol Festival (2013).,,?月?日,,Aria Shibuya,1
|
5147,澁谷ありあ,ありあ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5147,5147,,,?月?日,,888888,888888,澁谷ありあ,Aria Shibuya,Aria Shibuya,Imported from School Idol Festival (2013).,,?月?日,,Aria Shibuya,1
|
||||||
5148,かのんの母,かのんの母,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5148,5148,,,?月?日,,888888,888888,かのんの母,Kanon's mother,Kanon's mother,Imported from School Idol Festival (2013).,,?月?日,,Kanon's mother,1
|
5148,かのんの母,かのんの母,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5148,5148,,,?月?日,,888888,888888,かのんの母,Kanon's mother,Kanon's mother,Imported from School Idol Festival (2013).,,?月?日,,Kanon's mother,1
|
||||||
5149,マンマル,マンマル,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5149,5149,,,?月?日,,888888,888888,マンマル,Manmaru,Manmaru,Imported from School Idol Festival (2013).,,?月?日,,Manmaru,1
|
5149,マンマル,マンマル,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5149,5149,,,?月?日,,888888,888888,マンマル,Manmaru,Manmaru,Imported from School Idol Festival (2013).,,?月?日,,Manmaru,1
|
||||||
5150,チビ,チビ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5150,5150,,,?月?日,,888888,888888,チビ,Chibi,Chibi,Imported from School Idol Festival (2013).,,?月?日,,Chibi,1
|
5150,チビ,チビ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5150,5150,,,?月?日,,888888,888888,チビ,Chibi,Chibi,Imported from School Idol Festival (2013).,,?月?日,,Chibi,1
|
||||||
5151,C.C.,C.C.,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5151,5151,,,?月?日,,888888,888888,C.C.,C.C.,C.C.,Imported from School Idol Festival (2013).,,?月?日,,C.C.,1
|
5151,C.C.,C.C.,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5151,5151,,,?月?日,,888888,888888,C.C.,C.C.,C.C.,Imported from School Idol Festival (2013).,,?月?日,,C.C.,1
|
||||||
5152,アーニャ,アーニャ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5152,5152,,,?月?日,,888888,888888,アーニャ,Anya,Anya,Imported from School Idol Festival (2013).,,?月?日,,Anya,1
|
5152,アーニャ,アーニャ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5152,5152,,,?月?日,,888888,888888,アーニャ,Anya,Anya,Imported from School Idol Festival (2013).,,?月?日,,Anya,1
|
||||||
5153,ヴィレッタ,ヴィレッタ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5153,5153,,,?月?日,,888888,888888,ヴィレッタ,Villetta,Villetta,Imported from School Idol Festival (2013).,,?月?日,,Villetta,1
|
5153,ヴィレッタ,ヴィレッタ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5153,5153,,,?月?日,,888888,888888,ヴィレッタ,Villetta,Villetta,Imported from School Idol Festival (2013).,,?月?日,,Villetta,1
|
||||||
5154,カレン,カレン,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5154,5154,,,?月?日,,888888,888888,カレン,Kallen,Kallen,Imported from School Idol Festival (2013).,,?月?日,,Kallen,1
|
5154,カレン,カレン,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5154,5154,,,?月?日,,888888,888888,カレン,Kallen,Kallen,Imported from School Idol Festival (2013).,,?月?日,,Kallen,1
|
||||||
5155,コーネリア,コーネリア,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5155,5155,,,?月?日,,888888,888888,コーネリア,Cornelia,Cornelia,Imported from School Idol Festival (2013).,,?月?日,,Cornelia,1
|
5155,コーネリア,コーネリア,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5155,5155,,,?月?日,,888888,888888,コーネリア,Cornelia,Cornelia,Imported from School Idol Festival (2013).,,?月?日,,Cornelia,1
|
||||||
5156,シャーリー,シャーリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5156,5156,,,?月?日,,888888,888888,シャーリー,Shirley,Shirley,Imported from School Idol Festival (2013).,,?月?日,,Shirley,1
|
5156,シャーリー,シャーリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5156,5156,,,?月?日,,888888,888888,シャーリー,Shirley,Shirley,Imported from School Idol Festival (2013).,,?月?日,,Shirley,1
|
||||||
5157,セシル,セシル,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5157,5157,,,?月?日,,888888,888888,セシル,Cecile,Cecile,Imported from School Idol Festival (2013).,,?月?日,,Cecile,1
|
5157,セシル,セシル,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5157,5157,,,?月?日,,888888,888888,セシル,Cecile,Cecile,Imported from School Idol Festival (2013).,,?月?日,,Cecile,1
|
||||||
5158,ナナリー,ナナリー,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5158,5158,,,?月?日,,888888,888888,ナナリー,Nunnally,Nunnally,Imported from School Idol Festival (2013).,,?月?日,,Nunnally,1
|
5158,ナナリー,ナナリー,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5158,5158,,,?月?日,,888888,888888,ナナリー,Nunnally,Nunnally,Imported from School Idol Festival (2013).,,?月?日,,Nunnally,1
|
||||||
5159,ニーナ,ニーナ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5159,5159,,,?月?日,,888888,888888,ニーナ,Nina,Nina,Imported from School Idol Festival (2013).,,?月?日,,Nina,1
|
5159,ニーナ,ニーナ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5159,5159,,,?月?日,,888888,888888,ニーナ,Nina,Nina,Imported from School Idol Festival (2013).,,?月?日,,Nina,1
|
||||||
5160,ノネット,ノネット,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5160,5160,,,?月?日,,888888,888888,ノネット,Nonette,Nonette,Imported from School Idol Festival (2013).,,?月?日,,Nonette,1
|
5160,ノネット,ノネット,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5160,5160,,,?月?日,,888888,888888,ノネット,Nonette,Nonette,Imported from School Idol Festival (2013).,,?月?日,,Nonette,1
|
||||||
5161,マリアンヌ,マリアンヌ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5161,5161,,,?月?日,,888888,888888,マリアンヌ,Marianne,Marianne,Imported from School Idol Festival (2013).,,?月?日,,Marianne,1
|
5161,マリアンヌ,マリアンヌ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5161,5161,,,?月?日,,888888,888888,マリアンヌ,Marianne,Marianne,Imported from School Idol Festival (2013).,,?月?日,,Marianne,1
|
||||||
5162,ミレイ,ミレイ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5162,5162,,,?月?日,,888888,888888,ミレイ,Milly,Milly,Imported from School Idol Festival (2013).,,?月?日,,Milly,1
|
5162,ミレイ,ミレイ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5162,5162,,,?月?日,,888888,888888,ミレイ,Milly,Milly,Imported from School Idol Festival (2013).,,?月?日,,Milly,1
|
||||||
5163,モニカ,モニカ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5163,5163,,,?月?日,,888888,888888,モニカ,Monica,Monica,Imported from School Idol Festival (2013).,,?月?日,,Monica,1
|
5163,モニカ,モニカ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5163,5163,,,?月?日,,888888,888888,モニカ,Monica,Monica,Imported from School Idol Festival (2013).,,?月?日,,Monica,1
|
||||||
5164,ユーフェミア,ユーフェミア,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5164,5164,,,?月?日,,888888,888888,ユーフェミア,Euphemia,Euphemia,Imported from School Idol Festival (2013).,,?月?日,,Euphemia,1
|
5164,ユーフェミア,ユーフェミア,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5164,5164,,,?月?日,,888888,888888,ユーフェミア,Euphemia,Euphemia,Imported from School Idol Festival (2013).,,?月?日,,Euphemia,1
|
||||||
5165,ラクシャータ,ラクシャータ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5165,5165,,,?月?日,,888888,888888,ラクシャータ,Rakshata,Rakshata,Imported from School Idol Festival (2013).,,?月?日,,Rakshata,1
|
5165,ラクシャータ,ラクシャータ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5165,5165,,,?月?日,,888888,888888,ラクシャータ,Rakshata,Rakshata,Imported from School Idol Festival (2013).,,?月?日,,Rakshata,1
|
||||||
5166,ローマイヤ,ローマイヤ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5166,5166,,,?月?日,,888888,888888,ローマイヤ,Lohmeyer,Lohmeyer,Imported from School Idol Festival (2013).,,?月?日,,Lohmeyer,1
|
5166,ローマイヤ,ローマイヤ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5166,5166,,,?月?日,,888888,888888,ローマイヤ,Lohmeyer,Lohmeyer,Imported from School Idol Festival (2013).,,?月?日,,Lohmeyer,1
|
||||||
5167,咲世子,咲世子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5167,5167,,,?月?日,,888888,888888,咲世子,Sayoko,Sayoko,Imported from School Idol Festival (2013).,,?月?日,,Sayoko,1
|
5167,咲世子,咲世子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5167,5167,,,?月?日,,888888,888888,咲世子,Sayoko,Sayoko,Imported from School Idol Festival (2013).,,?月?日,,Sayoko,1
|
||||||
5168,周香凛,香凛,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5168,5168,,,?月?日,,888888,888888,周香凛,Zhou Xianglin,Zhou Xianglin,Imported from School Idol Festival (2013).,,?月?日,,Zhou Xianglin,1
|
5168,周香凛,香凛,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5168,5168,,,?月?日,,888888,888888,周香凛,Zhou Xianglin,Zhou Xianglin,Imported from School Idol Festival (2013).,,?月?日,,Zhou Xianglin,1
|
||||||
5169,神楽那,神楽那,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5169,5169,,,?月?日,,888888,888888,神楽那,Kaguya,Kaguya,Imported from School Idol Festival (2013).,,?月?日,,Kaguya,1
|
5169,神楽那,神楽那,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5169,5169,,,?月?日,,888888,888888,神楽那,Kaguya,Kaguya,Imported from School Idol Festival (2013).,,?月?日,,Kaguya,1
|
||||||
5170,千葉凪沙,凪沙,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5170,5170,,,?月?日,,888888,888888,千葉凪沙,Nagisa Chiba,Nagisa Chiba,Imported from School Idol Festival (2013).,,?月?日,,Nagisa Chiba,1
|
5170,千葉凪沙,凪沙,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5170,5170,,,?月?日,,888888,888888,千葉凪沙,Nagisa Chiba,Nagisa Chiba,Imported from School Idol Festival (2013).,,?月?日,,Nagisa Chiba,1
|
||||||
5171,天子,天子,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5171,5171,,,?月?日,,888888,888888,天子,Emperor,Emperor,Imported from School Idol Festival (2013).,,?月?日,,Emperor,1
|
5171,天子,天子,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5171,5171,,,?月?日,,888888,888888,天子,Emperor,Emperor,Imported from School Idol Festival (2013).,,?月?日,,Emperor,1
|
||||||
5172,シャムナ,シャムナ,School Idol Festival(2013)のキャラクター。,OTHER,0,1,9000,5172,5172,,,?月?日,,888888,888888,シャムナ,Shamuna,Shamuna,Imported from School Idol Festival (2013).,,?月?日,,Shamuna,1
|
5172,シャムナ,シャムナ,School Idol Festival(2013)のキャラクター。,6,0,1,9000,5172,5172,,,?月?日,,888888,888888,シャムナ,Shamuna,Shamuna,Imported from School Idol Festival (2013).,,?月?日,,Shamuna,1
|
||||||
|
|||||||
|
@@ -38,4 +38,4 @@
|
|||||||
600,ヨハネ,7,#000000,494949,6001,0,0,1,,Yohane,223062001
|
600,ヨハネ,7,#000000,494949,6001,0,0,1,,Yohane,223062001
|
||||||
9999,その他,6,b8826d,b8826d,90000,1,0,1,,Other,1
|
9999,その他,6,b8826d,b8826d,90000,1,0,1,,Other,1
|
||||||
9901,幻日のヨハネ,6,b8826d,494949,99001,0,0,0,,Yohane the Parhelion,223121403
|
9901,幻日のヨハネ,6,b8826d,494949,99001,0,0,0,,Yohane the Parhelion,223121403
|
||||||
9000,スクフェス,OTHER,e4007f,e4007f,89999,0,0,0,,SIF,1
|
9000,スクフェス,6,e4007f,e4007f,89999,0,0,0,,SIF,1
|
||||||
|
|||||||
|
@@ -16,6 +16,34 @@ fn index_by(items: &JsonValue, key: &str) -> JsonValue {
|
|||||||
info
|
info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missions in an id band grouped by the character they target, as
|
||||||
|
// [requiredBond, missionId] ordered by required bond. Read from masterdata
|
||||||
|
// rather than derived from the character id: the ids do not follow character
|
||||||
|
// order (4010/4011 sit at 1158079/1158080, after the 15000 tier), and
|
||||||
|
// characters added by a card import have none at all.
|
||||||
|
fn missions_by_character(lo: i64, hi: i64) -> JsonValue {
|
||||||
|
let mut acc: std::collections::HashMap<String, Vec<(i64, i64)>> = std::collections::HashMap::new();
|
||||||
|
for data in t("mission").members() {
|
||||||
|
let id = data["id"].as_i64().unwrap_or(0);
|
||||||
|
if id < lo || id >= hi || data["conditionValues"].len() != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
acc.entry(data["conditionValues"][0].to_string())
|
||||||
|
.or_default()
|
||||||
|
.push((data["conditionNumber"].as_i64().unwrap_or(0), id));
|
||||||
|
}
|
||||||
|
let mut info = object! {};
|
||||||
|
for (character, mut list) in acc {
|
||||||
|
list.sort();
|
||||||
|
let mut entries = array![];
|
||||||
|
for (required, id) in list {
|
||||||
|
entries.push(array![required, id]).unwrap();
|
||||||
|
}
|
||||||
|
info[&character] = entries;
|
||||||
|
}
|
||||||
|
info
|
||||||
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref STORY: JsonValue = index_by(&t("story_part"), "id");
|
pub static ref STORY: JsonValue = index_by(&t("story_part"), "id");
|
||||||
|
|
||||||
@@ -203,6 +231,11 @@ lazy_static! {
|
|||||||
|
|
||||||
pub static ref MISSION_LIST: JsonValue = index_by(&t("mission"), "id");
|
pub static ref MISSION_LIST: JsonValue = index_by(&t("mission"), "id");
|
||||||
|
|
||||||
|
pub static ref CHARACTER_BOND_MISSIONS: JsonValue = missions_by_character(1158000, 1159000);
|
||||||
|
|
||||||
|
// 15 per character, aligned with live::CHATS.
|
||||||
|
pub static ref CHARACTER_CHAT_MISSIONS: JsonValue = missions_by_character(1958000, 1960000);
|
||||||
|
|
||||||
pub static ref CHARACTER_CHATS: JsonValue = {
|
pub static ref CHARACTER_CHATS: JsonValue = {
|
||||||
let mut info = object! {};
|
let mut info = object! {};
|
||||||
for data in t("mission").members() {
|
for data in t("mission").members() {
|
||||||
@@ -269,6 +302,57 @@ lazy_static! {
|
|||||||
info
|
info
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// const.csv keyed by _id. Values are strings in masterdata, exactly as the
|
||||||
|
// client reads them (ConstMst._value + StringExtensions.ToIntOrDefault).
|
||||||
|
pub static ref CONST: JsonValue = index_by(&t("const"), "id");
|
||||||
|
|
||||||
|
pub static ref LIVE_BOOST: JsonValue = index_by(&t("live_boost"), "value");
|
||||||
|
|
||||||
|
// The stamps every account starts with (chat_stamp._initialStamp), in masterdata
|
||||||
|
// order. Officially this is the whole of a fresh account's master_chat_stamp_ids —
|
||||||
|
// captured /api/chat/home responses open with exactly this list before an account's
|
||||||
|
// earned stamps are appended (see chat::tests::the_initial_stamp_set_matches_official).
|
||||||
|
pub static ref INITIAL_CHAT_STAMPS: JsonValue = {
|
||||||
|
let mut ids = array![];
|
||||||
|
for data in t("chat_stamp").members() {
|
||||||
|
if data["initialStamp"].as_i64().unwrap_or(0) == 1 {
|
||||||
|
ids.push(data["id"].clone()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
|
||||||
|
pub static ref EVENTS: JsonValue = index_by(&t("event"), "id");
|
||||||
|
|
||||||
|
// release_label.csv keyed by _id — the open/close window masterdata rows are gated
|
||||||
|
// on. _openedAt / _closedAt are blank for the evergreen label (id 1).
|
||||||
|
pub static ref RELEASE_LABEL: JsonValue = index_by(&t("release_label"), "id");
|
||||||
|
|
||||||
|
// event_score.csv keyed by _masterEventId (Shock.EventScoreMst) — the per-event
|
||||||
|
// event-point yield of one live. Ratios are 1/10000, like every other ratio the
|
||||||
|
// client divides by COMMON_CONST.RATIO_DIVISOR.
|
||||||
|
pub static ref EVENT_SCORE: JsonValue = index_by(&t("event_score"), "masterEventId");
|
||||||
|
|
||||||
|
// music_level rows keyed "{masterMusicId}_{level}" — _fullCombo is the note
|
||||||
|
// count the multi-live miss/great-perfect ratios are measured against.
|
||||||
|
pub static ref MUSIC_LEVEL: JsonValue = {
|
||||||
|
let mut info = object! {};
|
||||||
|
for data in t("music_level").members() {
|
||||||
|
info[format!("{}_{}", data["masterMusicId"], data["level"])] = data.clone();
|
||||||
|
}
|
||||||
|
info
|
||||||
|
};
|
||||||
|
|
||||||
|
// multievent_rankbonus keyed "{playerCount}_{liveRank}" — _eventPtBonus is a
|
||||||
|
// ratio in 1/10000 (the client renders these as `sum / 100` percent).
|
||||||
|
pub static ref MULTIEVENT_RANK_BONUS: JsonValue = {
|
||||||
|
let mut info = object! {};
|
||||||
|
for data in t("multievent_rankbonus").members() {
|
||||||
|
info[format!("{}_{}", data["playerCount"], data["liveRank"])] = data.clone();
|
||||||
|
}
|
||||||
|
info
|
||||||
|
};
|
||||||
|
|
||||||
pub static ref RANKS: JsonValue = t("user_rank");
|
pub static ref RANKS: JsonValue = t("user_rank");
|
||||||
|
|
||||||
pub static ref USER_RANK_REWARD: JsonValue = {
|
pub static ref USER_RANK_REWARD: JsonValue = {
|
||||||
|
|||||||
@@ -25,7 +25,39 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_event_data(key: &str, event_id: u32) -> JsonValue {
|
// Whether a release_label window is open at `now`. A blank _openedAt has always been
|
||||||
|
// open and a blank _closedAt never closes, which is how the evergreen label (id 1) is
|
||||||
|
// expressed. _releaseStatus other than 1 is never released at all.
|
||||||
|
pub fn release_label_is_open(label_id: i64, now: u64) -> bool {
|
||||||
|
let label = &databases::RELEASE_LABEL[label_id.to_string()];
|
||||||
|
if label.is_empty() || label["releaseStatus"].as_i64().unwrap_or(0) != 1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(opened) = global::parse_datetime(&label["openedAt"].to_string()) {
|
||||||
|
if now < opened {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(closed) = global::parse_datetime(&label["closedAt"].to_string()) {
|
||||||
|
if now > closed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether an event is currently running, by its own release label window. ew had no
|
||||||
|
// in-session test before this — nothing else evaluates release_label — so this is the
|
||||||
|
// one place to extend if the event listing ever needs the same question answered.
|
||||||
|
pub fn is_in_session(event_id: u32, now: u64) -> bool {
|
||||||
|
let event = &databases::EVENTS[event_id.to_string()];
|
||||||
|
if event.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
release_label_is_open(event["masterReleaseLabelId"].as_i64().unwrap_or(0), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_event_data(key: &str, event_id: u32) -> JsonValue {
|
||||||
let mut event = userdata::get_acc_event(key);
|
let mut event = userdata::get_acc_event(key);
|
||||||
let is_star_event = STAR_EVENT_IDS.contains(&event_id);
|
let is_star_event = STAR_EVENT_IDS.contains(&event_id);
|
||||||
//println!("is_star_event: {}, {}", is_star_event, event_id);
|
//println!("is_star_event: {}, {}", is_star_event, event_id);
|
||||||
@@ -50,10 +82,61 @@ fn get_event_data(key: &str, event_id: u32) -> JsonValue {
|
|||||||
event[event_id.to_string()]["star_event"]["star_event_bonus_daily_count"] = 0.into();
|
event[event_id.to_string()]["star_event"]["star_event_bonus_daily_count"] = 0.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalise_event_shape(&mut event[event_id.to_string()]);
|
||||||
|
|
||||||
event[event_id.to_string()].clone()
|
event[event_id.to_string()].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_event_data(key: &str, event_id: u32, data: JsonValue) {
|
// The client's /api/event response types are [Serializable] C# classes, and Unity's
|
||||||
|
// JsonUtility maps them structurally: `score_ranking`, `member_ranking` and `lottery_box`
|
||||||
|
// are OBJECTS (Shock.ProtocolData.ScoreRanking / MemberRanking / LotteryBox), not arrays.
|
||||||
|
// new_user_event.json used to seed them as `[]`, which the client cannot bind - and
|
||||||
|
// MngEventData.SendGetEvent's success callback dereferences `r.data.score_ranking` and
|
||||||
|
// friends unguarded, so a mis-shaped payload takes out the whole recv and its `onComplete`
|
||||||
|
// is never called. That callback is the completion of a GATING TaskFlow.Step in
|
||||||
|
// LiveRestartSelectScene.ReloadScene, so the scene hangs on its loading screen forever.
|
||||||
|
//
|
||||||
|
// The template is fixed, but accounts created before that keep their stored blob, so the
|
||||||
|
// shapes are normalised on the way out as well. Coercion only ever replaces a value the
|
||||||
|
// client could not have parsed anyway; a well-formed object is left exactly as it is.
|
||||||
|
// `set_member` already writes member_ranking as an object, which is the shape this agrees
|
||||||
|
// with.
|
||||||
|
fn normalise_event_shape(event: &mut JsonValue) {
|
||||||
|
fn ensure_object(slot: &mut JsonValue, template: JsonValue) {
|
||||||
|
if !slot.is_object() {
|
||||||
|
*slot = template;
|
||||||
|
} else {
|
||||||
|
for (key, value) in template.entries() {
|
||||||
|
if slot[key].is_null() {
|
||||||
|
slot[key] = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_object(&mut event["point_ranking"], object! { rank: 0, point: 0 });
|
||||||
|
ensure_object(&mut event["score_ranking"], object! { all_rank: 0, group_rank: 0, score: 0 });
|
||||||
|
ensure_object(
|
||||||
|
&mut event["member_ranking"],
|
||||||
|
object! { master_character_id: 0, rank: 0, point: 0 },
|
||||||
|
);
|
||||||
|
ensure_object(
|
||||||
|
&mut event["lottery_box"],
|
||||||
|
object! { master_lottery_id: 0, reset_count: 0, draw_count_list: array![] },
|
||||||
|
);
|
||||||
|
// Read by EventData.SetMultiParameter (help count, penalty window, disconnect flag);
|
||||||
|
// absent keys bind as 0/false, but sending them keeps the payload self-describing.
|
||||||
|
for key in ["is_disconnected", "help_count", "penalty_remaining_time"] {
|
||||||
|
if event[key].is_null() {
|
||||||
|
event[key] = 0.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !event["mission_list"].is_array() {
|
||||||
|
event["mission_list"] = array![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_event_data(key: &str, event_id: u32, data: JsonValue) {
|
||||||
let mut event = userdata::get_acc_event(key);
|
let mut event = userdata::get_acc_event(key);
|
||||||
|
|
||||||
// Check for old version of event data
|
// Check for old version of event data
|
||||||
@@ -209,7 +292,7 @@ async fn set_member(Session { key, body }: Session) -> impl Responder {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_rank(event: u32, user_id: u64) -> u32 {
|
pub fn get_rank(event: u32, user_id: u64) -> u32 {
|
||||||
let scores = crate::router::event_ranking::get_raw_info(event);
|
let scores = crate::router::event_ranking::get_raw_info(event);
|
||||||
|
|
||||||
let mut i=1;
|
let mut i=1;
|
||||||
@@ -223,7 +306,7 @@ fn get_rank(event: u32, user_id: u64) -> u32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder {
|
async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder {
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
let master_event_id = body["master_event_id"].as_u32().unwrap();
|
let master_event_id = body["master_event_id"].as_u32().unwrap();
|
||||||
let scores = crate::router::event_ranking::get_scores_json().await[master_event_id as usize].clone();
|
let scores = crate::router::event_ranking::get_scores_json().await[master_event_id as usize].clone();
|
||||||
let mut rv = array![];
|
let mut rv = array![];
|
||||||
@@ -232,9 +315,7 @@ async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder {
|
|||||||
for score in scores.members() {
|
for score in scores.members() {
|
||||||
if i >= start && start + body["count"].as_u32().unwrap() >= i {
|
if i >= start && start + body["count"].as_u32().unwrap() >= i {
|
||||||
let mut entry = score.clone();
|
let mut entry = score.clone();
|
||||||
if !custom_cards {
|
crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"], protocol);
|
||||||
crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"]);
|
|
||||||
}
|
|
||||||
rv.push(entry).unwrap();
|
rv.push(entry).unwrap();
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
@@ -256,10 +337,14 @@ fn get_star_rank(points: i64) -> i64 {
|
|||||||
|
|
||||||
const LIMIT_COINS: i64 = 2000000000;
|
const LIMIT_COINS: i64 = 2000000000;
|
||||||
|
|
||||||
fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
|
// The row is keyed by BOTH the event and the point type, exactly as get_points reads it
|
||||||
|
// back. Matching on the type alone credited whichever event's row happened to come first
|
||||||
|
// in the list — an account that had ever played a different event got its points there,
|
||||||
|
// and get_points (which does check the id) then reported 0 for the event just played.
|
||||||
|
pub fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
|
||||||
let mut has = false;
|
let mut has = false;
|
||||||
for data in user["event_point_list"].members_mut() {
|
for data in user["event_point_list"].members_mut() {
|
||||||
if data["type"] == 1 {
|
if data["type"] == 1 && data["master_event_id"] == event_id {
|
||||||
has = true;
|
has = true;
|
||||||
let new_amount = data["amount"].as_i64().unwrap() + amount;
|
let new_amount = data["amount"].as_i64().unwrap() + amount;
|
||||||
if new_amount > LIMIT_COINS {
|
if new_amount > LIMIT_COINS {
|
||||||
@@ -280,7 +365,7 @@ fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_points(event_id: u32, user: &JsonValue) -> i64 {
|
pub fn get_points(event_id: u32, user: &JsonValue) -> i64 {
|
||||||
for data in user["event_point_list"].members() {
|
for data in user["event_point_list"].members() {
|
||||||
if data["type"] == 1 && data["master_event_id"] == event_id {
|
if data["type"] == 1 && data["master_event_id"] == event_id {
|
||||||
return data["amount"].as_i64().unwrap()
|
return data["amount"].as_i64().unwrap()
|
||||||
@@ -368,3 +453,96 @@ async fn event_end(req: HttpRequest, Session { key, body }: Session) -> impl Res
|
|||||||
async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||||
Api(event_live(&req, &key, &body, true))
|
Api(event_live(&req, &key, &body, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The client binds these with Unity's JsonUtility against [Serializable] classes:
|
||||||
|
// ScoreRanking { all_rank, group_rank, score }, MemberRanking { master_character_id,
|
||||||
|
// rank, point }, PointRanking { rank, point }, LotteryBox { master_lottery_id,
|
||||||
|
// reset_count, draw_count_list }. An array where an object is expected cannot bind, and
|
||||||
|
// MngEventData.SendGetEvent's callback dereferences them unguarded.
|
||||||
|
#[test]
|
||||||
|
fn the_new_user_template_already_has_the_client_shapes() {
|
||||||
|
let template: JsonValue =
|
||||||
|
jzon::parse(&include_file!("src/router/userdata/new_user_event.json")).unwrap();
|
||||||
|
for key in ["point_ranking", "score_ranking", "member_ranking", "lottery_box"] {
|
||||||
|
assert!(template[key].is_object(), "{} must be an object", key);
|
||||||
|
}
|
||||||
|
assert!(template["mission_list"].is_array());
|
||||||
|
assert_eq!(template["score_ranking"]["all_rank"], 0);
|
||||||
|
assert_eq!(template["member_ranking"]["master_character_id"], 0);
|
||||||
|
assert_eq!(template["lottery_box"]["draw_count_list"], array![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_blobs_with_the_old_array_shapes_are_coerced() {
|
||||||
|
// Exactly what accounts created before the template fix have on disk.
|
||||||
|
let mut stored = object! {
|
||||||
|
point_ranking: object! { point: 12 },
|
||||||
|
score_ranking: array![],
|
||||||
|
member_ranking: array![],
|
||||||
|
lottery_box: array![],
|
||||||
|
mission_list: array![],
|
||||||
|
};
|
||||||
|
normalise_event_shape(&mut stored);
|
||||||
|
|
||||||
|
assert!(stored["score_ranking"].is_object());
|
||||||
|
assert_eq!(stored["score_ranking"]["all_rank"], 0);
|
||||||
|
assert!(stored["member_ranking"].is_object());
|
||||||
|
assert!(stored["lottery_box"].is_object());
|
||||||
|
assert_eq!(stored["lottery_box"]["draw_count_list"], array![]);
|
||||||
|
// A key that was already there survives; the missing sibling is filled in.
|
||||||
|
assert_eq!(stored["point_ranking"]["point"], 12);
|
||||||
|
assert_eq!(stored["point_ranking"]["rank"], 0);
|
||||||
|
// The multi trio EventData.SetMultiParameter reads.
|
||||||
|
assert_eq!(stored["is_disconnected"], 0);
|
||||||
|
assert_eq!(stored["help_count"], 0);
|
||||||
|
assert_eq!(stored["penalty_remaining_time"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_formed_data_is_left_alone() {
|
||||||
|
let mut live = object! {
|
||||||
|
point_ranking: object! { rank: 3, point: 900 },
|
||||||
|
score_ranking: object! { all_rank: 7, group_rank: 2, score: 4242 },
|
||||||
|
member_ranking: object! { master_character_id: 5, rank: 1, point: 10 },
|
||||||
|
lottery_box: object! { master_lottery_id: 8, reset_count: 1, draw_count_list: array![] },
|
||||||
|
mission_list: array![],
|
||||||
|
is_disconnected: 1,
|
||||||
|
help_count: 4,
|
||||||
|
penalty_remaining_time: 600,
|
||||||
|
};
|
||||||
|
let before = live.clone();
|
||||||
|
normalise_event_shape(&mut live);
|
||||||
|
assert_eq!(live, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// give_event_points and get_points must agree on what identifies a row. They did not:
|
||||||
|
// the write matched on the point type alone, so an account that had ever earned points
|
||||||
|
// in ANY event credited every later event to that first row — and get_points, which
|
||||||
|
// does compare the event id, then reported 0 for the event actually played.
|
||||||
|
#[test]
|
||||||
|
fn event_points_land_in_the_row_for_that_event() {
|
||||||
|
let mut user = object!{ event_point_list: array![] };
|
||||||
|
|
||||||
|
give_event_points(108, 35, &mut user);
|
||||||
|
assert_eq!(get_points(108, &user), 35);
|
||||||
|
|
||||||
|
// A second event opens a row of its own and leaves the first one alone.
|
||||||
|
give_event_points(111, 70, &mut user);
|
||||||
|
assert_eq!(get_points(111, &user), 70);
|
||||||
|
assert_eq!(get_points(108, &user), 35);
|
||||||
|
assert_eq!(user["event_point_list"].len(), 2);
|
||||||
|
|
||||||
|
// And a second live in the first event still adds to the first row.
|
||||||
|
give_event_points(108, 35, &mut user);
|
||||||
|
assert_eq!(get_points(108, &user), 70);
|
||||||
|
assert_eq!(get_points(111, &user), 70);
|
||||||
|
assert_eq!(user["event_point_list"].len(), 2);
|
||||||
|
|
||||||
|
// An event never played is worth nothing, not somebody else's total.
|
||||||
|
assert_eq!(get_points(115, &user), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ fn get_json() -> JsonValue {
|
|||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
for score in scores.members() {
|
for score in scores.members() {
|
||||||
let user = guest::get_user(score["user"].as_i64().unwrap(), &object![], guest::UserView::Ranking, true);
|
// The cached ranking json keeps every card unproxied; event.rs
|
||||||
|
// re-proxies per requesting client's protocol version
|
||||||
|
let user = guest::get_user(score["user"].as_i64().unwrap(), &object![], guest::UserView::Ranking, u32::MAX);
|
||||||
rv[event.to_string()].push(object!{
|
rv[event.to_string()].push(object!{
|
||||||
"rank": i,
|
"rank": i,
|
||||||
"user_detail": user,
|
"user_detail": user,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn friend(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
async fn friend(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
let friends = userdata::get_acc_friends(&key);
|
let friends = userdata::get_acc_friends(&key);
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ async fn friend(req: HttpRequest, Session { key, body }: Session) -> impl Respon
|
|||||||
};
|
};
|
||||||
|
|
||||||
for uid in rv_data.members() {
|
for uid in rv_data.members() {
|
||||||
let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards);
|
let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol);
|
||||||
user["user"]["last_login_time"] = global::set_time(user["user"]["last_login_time"].as_u64().unwrap_or(0), user_id, false).into();
|
user["user"]["last_login_time"] = global::set_time(user["user"]["last_login_time"].as_u64().unwrap_or(0), user_id, false).into();
|
||||||
rv.push(user).unwrap();
|
rv.push(user).unwrap();
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ async fn ids(Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder {
|
async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
let friends = userdata::get_acc_friends(&key);
|
let friends = userdata::get_acc_friends(&key);
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder {
|
|||||||
|
|
||||||
let mut rv = array![];
|
let mut rv = array![];
|
||||||
for uid in random.members() {
|
for uid in random.members() {
|
||||||
let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards);
|
let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol);
|
||||||
if user["user"]["friend_request_disabled"] == 1 || user.is_empty() {
|
if user["user"]["friend_request_disabled"] == 1 || user.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ async fn search(req: HttpRequest, Session { key, body }: Session) -> impl Respon
|
|||||||
let friends = userdata::get_acc_friends(&key);
|
let friends = userdata::get_acc_friends(&key);
|
||||||
|
|
||||||
let uid = body["user_id"].as_i64().unwrap();
|
let uid = body["user_id"].as_i64().unwrap();
|
||||||
let user = guest::get_user(uid, &friends, guest::UserView::Detail, crate::router::card::client_supports_custom_cards(&req));
|
let user = guest::get_user(uid, &friends, guest::UserView::Detail, crate::router::global::client_protocol_version(&req));
|
||||||
|
|
||||||
Api(Some(if user.is_empty() {
|
Api(Some(if user.is_empty() {
|
||||||
array![]
|
array![]
|
||||||
|
|||||||
@@ -23,18 +23,32 @@ struct AssetVersion {
|
|||||||
|
|
||||||
static ASSET_VERSIONS: &[AssetVersion] = &[
|
static ASSET_VERSIONS: &[AssetVersion] = &[
|
||||||
// Default / stock
|
// Default / stock
|
||||||
AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: true },
|
AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: false },
|
||||||
AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: true },
|
AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: false },
|
||||||
AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: true },
|
AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: false },
|
||||||
AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: true },
|
AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: false },
|
||||||
|
|
||||||
// Re-written client versions 2.0.0 - 2.1.2 (windows only)
|
// Re-written client versions 2.0.0 - 2.1.2 (windows only)
|
||||||
AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "4ed1d077df2d1b29e17d25d64fb37242", latest: false },
|
AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "4ed1d077df2d1b29e17d25d64fb37242", latest: false },
|
||||||
|
|
||||||
// Re-written client versions 2.2.0 -
|
// Re-written client versions 2.2.0 - 2.2.2
|
||||||
AssetVersion { region: "JP", platform: "Windows", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "ec508163f04e0f9e0435b4302011d123", latest: true },
|
AssetVersion { region: "JP", platform: "Windows", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "ec508163f04e0f9e0435b4302011d123", latest: false },
|
||||||
AssetVersion { region: "JP", platform: "Android", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "27eabc1af04fa6e4a727516d2bbdadff", latest: false },
|
AssetVersion { region: "JP", platform: "Android", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "27eabc1af04fa6e4a727516d2bbdadff", latest: false },
|
||||||
AssetVersion { region: "JP", platform: "iOS", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "43e2337da344296964aec6c474fb0add", latest: false },
|
|
||||||
|
// Re-written client versions 2.3.0 - 2.3.2
|
||||||
|
AssetVersion { region: "JP", platform: "Windows", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "fd37b607ca271a6ffe17b1e2046c45ad", latest: false },
|
||||||
|
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 - 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: true },
|
||||||
|
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true },
|
||||||
|
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: true },
|
||||||
|
|
||||||
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
|
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
|
||||||
];
|
];
|
||||||
@@ -285,6 +299,48 @@ pub fn format_datetime(time: u64) -> String {
|
|||||||
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, secs / 3600, (secs % 3600) / 60, secs % 60)
|
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, secs / 3600, (secs % 3600) / 60, secs % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inverse of civil_from_days (Howard Hinnant's days_from_civil).
|
||||||
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let mp = if m > 2 { m - 3 } else { m + 9 };
|
||||||
|
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
era * 146097 + doe - 719468
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the datetime shape masterdata uses ("2023/06/19 5:00:00", also tolerating
|
||||||
|
// '-' separators and a missing time part) into the same naive seconds-since-epoch
|
||||||
|
// scale format_datetime prints. Naive on purpose: every consumer compares a
|
||||||
|
// masterdata timestamp against a server timestamp, so both sides share the offset.
|
||||||
|
pub fn parse_datetime(text: &str) -> Option<u64> {
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (date, time) = match text.split_once(' ') {
|
||||||
|
Some((date, time)) => (date, time),
|
||||||
|
None => (text, "0:0:0")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut date_parts = date.split(['/', '-']);
|
||||||
|
let y = date_parts.next()?.trim().parse::<i64>().ok()?;
|
||||||
|
let m = date_parts.next()?.trim().parse::<i64>().ok()?;
|
||||||
|
let d = date_parts.next()?.trim().parse::<i64>().ok()?;
|
||||||
|
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut time_parts = time.split(':');
|
||||||
|
let hh = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
|
||||||
|
let mm = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
|
||||||
|
let ss = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
|
||||||
|
|
||||||
|
let secs = days_from_civil(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss;
|
||||||
|
if secs < 0 { None } else { Some(secs as u64) }
|
||||||
|
}
|
||||||
|
|
||||||
fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) {
|
fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) {
|
||||||
let mut edited = false;
|
let mut edited = false;
|
||||||
let default_time = 1709272800;
|
let default_time = 1709272800;
|
||||||
|
|||||||
@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
|
|||||||
|
|
||||||
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
|
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
|
||||||
let body = jzon::parse(&body).unwrap();
|
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 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 {
|
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!{
|
object!{
|
||||||
result: "ERR",
|
result: "NG",
|
||||||
messsage: "User Not Found"
|
code: code,
|
||||||
|
message: message
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let data_user = userdata::get_acc(&user["login_token"].to_string());
|
let data_user = userdata::get_acc(&user["login_token"].to_string());
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ async fn home(Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
user["home"]["unread_chat_count"] = chat_count.into();
|
user["home"]["unread_chat_count"] = chat_count.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();
|
||||||
|
|
||||||
//todo
|
//todo
|
||||||
user["home"]["beginner_mission_complete"] = 1.into();
|
user["home"]["beginner_mission_complete"] = 1.into();
|
||||||
|
|
||||||
|
|||||||
@@ -300,8 +300,10 @@ pub fn lp_modification(user: &mut JsonValue, change_amount: u64, remove: bool) -
|
|||||||
stamina != orig_stamina || anchor != orig_anchor
|
stamina != orig_stamina || anchor != orig_anchor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Falls through to the custom-card db for the runtime band (card_info), so
|
||||||
|
// duplicate conversion / evolve costs / give_character work on those too
|
||||||
pub fn get_rarity(id: i64) -> i32 {
|
pub fn get_rarity(id: i64) -> i32 {
|
||||||
databases::CARD_LIST[id.to_string()]["rarity"].as_i32().unwrap_or(0)
|
crate::router::custom_card::card_info(id)["rarity"].as_i32().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// true - added
|
// true - added
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder};
|
|||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
use crate::router::{databases, global, items, userdata, Login, Session, Api};
|
use crate::router::{arcade, databases, global, items, userdata, Login, Session, Api};
|
||||||
use crate::router::clear_rate::live_completed;
|
use crate::router::clear_rate::live_completed;
|
||||||
use crate::router::tools::guest;
|
use crate::router::tools::guest;
|
||||||
|
|
||||||
@@ -25,9 +25,21 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
|
|||||||
|
|
||||||
|
|
||||||
async fn retire(Session { key, body }: Session) -> impl Responder {
|
async fn retire(Session { key, body }: Session) -> impl Responder {
|
||||||
|
// An arcade song whose life gauge emptied is played out and reported here,
|
||||||
|
// not at /live/end: there is no cleared flag on the end wire and live_end_ex
|
||||||
|
// records a clear unconditionally. So this is the only place the cabinet's
|
||||||
|
// ledger can learn about a failed song, and it has to look before live_retire
|
||||||
|
// sweeps the start record that proves the song was a cabinet's. Nothing about
|
||||||
|
// an ordinary retire changes: an unflagged start answers None here.
|
||||||
|
let arcade_user = arcade::arcade_retire_user(&key, &body);
|
||||||
live_retire(&key, &body);
|
live_retire(&key, &body);
|
||||||
|
if let Some(user_id) = arcade_user {
|
||||||
|
arcade::record_play(user_id, &body, false);
|
||||||
|
}
|
||||||
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 {
|
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 {
|
||||||
live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), true, 0, 0);
|
// Body-derived, so defaulted rather than unwrapped: a retire is not worth a
|
||||||
|
// panicked worker either (live_completed ignores an unknown id/level).
|
||||||
|
live_completed(body["master_live_id"].as_i64().unwrap_or(0), body["level"].as_i32().unwrap_or(0), true, 0, 0);
|
||||||
}
|
}
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
"stamina": {},
|
"stamina": {},
|
||||||
@@ -81,7 +93,7 @@ fn random_number(lowest: usize, highest: usize) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
|
async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
let friends = userdata::get_acc_friends(&key);
|
let friends = userdata::get_acc_friends(&key);
|
||||||
let user = userdata::get_acc(&key);
|
let user = userdata::get_acc(&key);
|
||||||
@@ -136,7 +148,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
} else {
|
} else {
|
||||||
if !friends["friend_user_id_list"].is_empty() {
|
if !friends["friend_user_id_list"].is_empty() {
|
||||||
guest_list.push(guest::get_user(friends["friend_user_id_list"][random_number(0, friends["friend_user_id_list"].len() - 1)].as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards)).unwrap();
|
guest_list.push(guest::get_user(friends["friend_user_id_list"][random_number(0, friends["friend_user_id_list"].len() - 1)].as_i64().unwrap(), &friends, guest::UserView::Card, protocol)).unwrap();
|
||||||
}
|
}
|
||||||
let expected: usize = 5;
|
let expected: usize = 5;
|
||||||
if guest_list.len() < expected {
|
if guest_list.len() < expected {
|
||||||
@@ -147,7 +159,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for uid in random.members() {
|
for uid in random.members() {
|
||||||
let guest = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards);
|
let guest = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol);
|
||||||
if guest["user"]["friend_request_disabled"] == 1 || guest.is_empty() {
|
if guest["user"]["friend_request_disabled"] == 1 || guest.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -222,12 +234,15 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) {
|
|||||||
let mut expired = array![];
|
let mut expired = array![];
|
||||||
let curr_time = global::timestamp();
|
let curr_time = global::timestamp();
|
||||||
for (i, live) in server_data["last_live_started"].members().enumerate() {
|
for (i, live) in server_data["last_live_started"].members().enumerate() {
|
||||||
if live["expire_date_time"].as_u64().unwrap() < curr_time || live["master_live_id"] == live_id {
|
// A record with no readable expiry is treated as expired rather than panicking:
|
||||||
|
// start_live always writes one, so this is a corrupt/hand-edited row.
|
||||||
|
let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time;
|
||||||
|
if stale || live["master_live_id"] == live_id {
|
||||||
expired.push(i).unwrap();
|
expired.push(i).unwrap();
|
||||||
}
|
}
|
||||||
if live["expire_date_time"].as_u64().unwrap() < curr_time {
|
if stale {
|
||||||
// User closed game after losing. Count this as a fail.
|
// User closed game after losing. Count this as a fail.
|
||||||
live_completed(live["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), true, 0, 0);
|
live_completed(live["master_live_id"].as_i64().unwrap_or(0), live["level"].as_i32().unwrap_or(0), true, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i in expired.members() {
|
for i in expired.members() {
|
||||||
@@ -243,34 +258,94 @@ fn get_end_live_deck_id(login_token: &str, body: &JsonValue) -> Option<i32> {
|
|||||||
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
|
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
|
||||||
let rv = server_data["last_live_started"][index]["deck_slot"].as_i32()?;
|
let rv = server_data["last_live_started"][index]["deck_slot"].as_i32()?;
|
||||||
|
|
||||||
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap());
|
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
|
||||||
userdata::save_server_data(login_token, server_data);
|
userdata::save_server_data(login_token, server_data);
|
||||||
Some(rv)
|
Some(rv)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option<u32> {
|
// The whole payload the client POSTed to */start for this live, as recorded by
|
||||||
|
// start_live. /multi_live/end needs the boost and deck slot out of it, and its
|
||||||
|
// own request body carries neither.
|
||||||
|
pub fn get_started_live(login_token: &str, body: &JsonValue) -> Option<JsonValue> {
|
||||||
let server_data = userdata::get_server_data(login_token);
|
let server_data = userdata::get_server_data(login_token);
|
||||||
if server_data["last_live_started"].is_null() {
|
if server_data["last_live_started"].is_null() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
|
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
|
||||||
let rv = server_data["last_live_started"][index]["master_event_id"].as_u32()?;
|
Some(server_data["last_live_started"][index].clone())
|
||||||
|
|
||||||
Some(rv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_retire(login_token: &str, body: &JsonValue) {
|
// Claims the record start_live left behind: it is returned AND removed in one atomic
|
||||||
|
// step, so of N ends arriving together for one live exactly one gets Some and every other
|
||||||
|
// gets None. /multi_live/end awards off that Some and answers a None from current state,
|
||||||
|
// which is what makes its duplicate protection deliberate rather than a side effect of
|
||||||
|
// get_end_live_deck_id's shape (that one only consumed when the record carried a numeric
|
||||||
|
// deck_slot, and only long after the awarding decision had been taken).
|
||||||
|
//
|
||||||
|
// The stale sweep check_for_stale_data does is folded in unchanged: records past their
|
||||||
|
// hour count as a fail and go, so live_end_ex's own later sweep finds nothing left to
|
||||||
|
// count twice. live_completed is called after the transaction commits - it writes to a
|
||||||
|
// different database and has no business running inside this one's write lock.
|
||||||
|
//
|
||||||
|
// Solo /live/end is deliberately NOT routed through here: it still consumes its record
|
||||||
|
// inside live_end_ex exactly as it always did.
|
||||||
|
pub fn take_started_live(login_token: &str, body: &JsonValue) -> Option<JsonValue> {
|
||||||
|
let live_id = body["master_live_id"].clone();
|
||||||
|
let curr_time = global::timestamp();
|
||||||
|
let mut failed: Vec<(i64, i32)> = Vec::new();
|
||||||
|
|
||||||
|
let taken = userdata::modify_server_data(login_token, |server_data| {
|
||||||
|
if server_data["last_live_started"].is_null() {
|
||||||
|
server_data["last_live_started"] = array![];
|
||||||
|
}
|
||||||
|
let mut taken: Option<JsonValue> = None;
|
||||||
|
let mut kept = array![];
|
||||||
|
for live in server_data["last_live_started"].members() {
|
||||||
|
let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time;
|
||||||
|
let mine = live["master_live_id"] == live_id;
|
||||||
|
if stale {
|
||||||
|
// User closed game after losing. Count this as a fail.
|
||||||
|
failed.push((
|
||||||
|
live["master_live_id"].as_i64().unwrap_or(0),
|
||||||
|
live["level"].as_i32().unwrap_or(0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if mine && taken.is_none() {
|
||||||
|
taken = Some(live.clone());
|
||||||
|
} else if !stale && !mine {
|
||||||
|
kept.push(live.clone()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server_data["last_live_started"] = kept;
|
||||||
|
taken
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, level) in failed {
|
||||||
|
live_completed(id, level, true, 0, 0);
|
||||||
|
}
|
||||||
|
taken
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option<u32> {
|
||||||
|
get_started_live(login_token, body)?["master_event_id"].as_u32()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn live_retire(login_token: &str, body: &JsonValue) {
|
||||||
let mut server_data = userdata::get_server_data(login_token);
|
let mut server_data = userdata::get_server_data(login_token);
|
||||||
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap());
|
// A body with no master_live_id matches nothing (0 is not a live id); the stale sweep
|
||||||
|
// still runs, which is all a retire with a malformed body can honestly do.
|
||||||
|
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
|
||||||
userdata::save_server_data(login_token, server_data);
|
userdata::save_server_data(login_token, server_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_live(login_token: &str, body: &JsonValue) {
|
pub fn start_live(login_token: &str, body: &JsonValue) {
|
||||||
let mut server_data = userdata::get_server_data(login_token);
|
let mut server_data = userdata::get_server_data(login_token);
|
||||||
if server_data["last_live_started"].is_null() {
|
if server_data["last_live_started"].is_null() {
|
||||||
server_data["last_live_started"] = array![];
|
server_data["last_live_started"] = array![];
|
||||||
}
|
}
|
||||||
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap());
|
// Body-derived: /multi_live/start forwards a body this server never validated, and a
|
||||||
|
// start with no live id must record a useless record rather than panic a worker.
|
||||||
|
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
|
||||||
let mut to_save = body.clone();
|
let mut to_save = body.clone();
|
||||||
// The user has 1 hour to complete a live
|
// The user has 1 hour to complete a live
|
||||||
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into();
|
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into();
|
||||||
@@ -280,6 +355,10 @@ fn start_live(login_token: &str, body: &JsonValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start(Session { key, body }: Session) -> impl Responder {
|
async fn start(Session { key, body }: Session) -> impl Responder {
|
||||||
|
// A live starting on an arcade cabinet is a sighting for that cabinet: its
|
||||||
|
// last_seen is what the TTL sweeper measures, and a machine in daily use
|
||||||
|
// must never age out from under its own players.
|
||||||
|
arcade::live_started(&key, &body);
|
||||||
start_live(&key, &body);
|
start_live(&key, &body);
|
||||||
Api(Some(array![]))
|
Api(Some(array![]))
|
||||||
}
|
}
|
||||||
@@ -311,24 +390,32 @@ fn get_clear_count(id: i64, user: &JsonValue) -> i64 {
|
|||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> JsonValue {
|
// `record_high_score=false` plays this live for the clear count and the max combo but
|
||||||
|
// leaves the stored high score alone: it enters the keep-best comparison below as 0, so
|
||||||
|
// the existing record always wins and is what gets reported back. That is the official
|
||||||
|
// treatment of a public multi live ("本イベントでは HIGH SCOREは更新されません"), which
|
||||||
|
// /multi_live/end asks for — see the score-board note in live_end_ex.
|
||||||
|
pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool, record_high_score: bool) -> JsonValue {
|
||||||
if user["tutorial_step"].as_i32().unwrap() < 130 {
|
if user["tutorial_step"].as_i32().unwrap() < 130 {
|
||||||
return JsonValue::Null;
|
return JsonValue::Null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every field here comes off the request body, so a malformed end must not panic a
|
||||||
|
// worker: a missing id/level/score reads as 0, which is what an empty live record
|
||||||
|
// would have held anyway.
|
||||||
let mut rv = object!{
|
let mut rv = object!{
|
||||||
"master_live_id": data["master_live_id"].as_i64().unwrap(),
|
"master_live_id": data["master_live_id"].as_i64().unwrap_or(0),
|
||||||
"level": data["level"].as_i64().unwrap(),
|
"level": data["level"].as_i64().unwrap_or(0),
|
||||||
"clear_count": 1,
|
"clear_count": 1,
|
||||||
"high_score": data["live_score"]["score"].as_i64().unwrap(),
|
"high_score": if record_high_score { data["live_score"]["score"].as_i64().unwrap_or(0) } else { 0 },
|
||||||
"max_combo": data["live_score"]["max_combo"].as_i64().unwrap(),
|
"max_combo": data["live_score"]["max_combo"].as_i64().unwrap_or(0),
|
||||||
"auto_enable": 1, //whats this?
|
"auto_enable": 1, //whats this?
|
||||||
"updated_time": global::timestamp()
|
"updated_time": global::timestamp()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut has = false;
|
let mut has = false;
|
||||||
for current in user["live_list"].members_mut() {
|
for current in user["live_list"].members_mut() {
|
||||||
if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap() == 0) {
|
if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap_or(0) == 0) {
|
||||||
has = true;
|
has = true;
|
||||||
if add {
|
if add {
|
||||||
rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into();
|
rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into();
|
||||||
@@ -348,7 +435,7 @@ pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> Js
|
|||||||
}
|
}
|
||||||
current["updated_time"] = rv["updated_time"].clone();
|
current["updated_time"] = rv["updated_time"].clone();
|
||||||
rv["level"] = current["level"].clone();
|
rv["level"] = current["level"].clone();
|
||||||
if data["level"].as_i32().unwrap() != 0 {
|
if data["level"].as_i32().unwrap_or(0) != 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,21 +547,9 @@ fn give_mission_rewards(user: &mut JsonValue, home: &mut JsonValue, missions: &J
|
|||||||
}
|
}
|
||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
pub fn get_master_num(id: i64) -> i64 {
|
// [requiredBond, missionId] pairs for a character's bond missions, lowest first.
|
||||||
let id = id.to_string();
|
pub fn bond_missions(character: i64) -> &'static JsonValue {
|
||||||
let mut masterid = 0;
|
&databases::CHARACTER_BOND_MISSIONS[character.to_string()]
|
||||||
if id.starts_with('2') {
|
|
||||||
masterid += 9;
|
|
||||||
} else if id.starts_with('3') {
|
|
||||||
masterid += 9 + 9;
|
|
||||||
} else if id.starts_with('4') {
|
|
||||||
masterid += 9 + 9 + 12;
|
|
||||||
}
|
|
||||||
masterid
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_master_id(id: i64) -> i64 {
|
|
||||||
get_master_num(id) + id.to_string().char_indices().last().unwrap().1.to_string().parse::<i64>().unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BOND: i64 = 500000;
|
const MAX_BOND: i64 = 500000;
|
||||||
@@ -526,7 +601,9 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
|
|||||||
Some(x) => x,
|
Some(x) => x,
|
||||||
None => continue
|
None => continue
|
||||||
};
|
};
|
||||||
let character = match databases::CARD_LIST[mcid.to_string()]["masterCharacterId"].as_i64() {
|
// card_info falls through to the custom-card db, so a runtime card in
|
||||||
|
// the deck still earns its character's bond
|
||||||
|
let character = match crate::router::custom_card::card_info(mcid)["masterCharacterId"].as_i64() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => continue
|
None => continue
|
||||||
};
|
};
|
||||||
@@ -550,18 +627,17 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
|
|||||||
let after = (before + gains[idx]).min(MAX_BOND);
|
let after = (before + gains[idx]).min(MAX_BOND);
|
||||||
set_character_bond(&mut character_list, character, after);
|
set_character_bond(&mut character_list, character, after);
|
||||||
|
|
||||||
if gains[idx] > 0 {
|
let tiers = bond_missions(character);
|
||||||
let mut mission_id = 1158000 + get_master_id(character);
|
if gains[idx] > 0 && !tiers.is_empty() {
|
||||||
let mut limit = 1500;
|
let mut tier = 0;
|
||||||
let mut status = items::get_mission_status(mission_id, missions);
|
let mut status = items::get_mission_status(tiers[0][1].as_i64().unwrap(), missions);
|
||||||
if status.is_empty() {
|
if status.is_empty() && tiers.len() > 1 {
|
||||||
mission_id += 39;
|
tier = 1;
|
||||||
limit *= 10;
|
status = items::get_mission_status(tiers[1][1].as_i64().unwrap(), missions);
|
||||||
status = items::get_mission_status(mission_id, missions);
|
|
||||||
}
|
}
|
||||||
if status["status"].as_i32().unwrap_or(0) <= 1 {
|
if status["status"].as_i32().unwrap_or(0) <= 1 {
|
||||||
let completed = after >= limit;
|
let completed = after >= tiers[tier][0].as_i64().unwrap();
|
||||||
if let Some(m) = items::update_mission_status(mission_id, 0, completed, false, gains[idx], missions) {
|
if let Some(m) = items::update_mission_status(tiers[tier][1].as_i64().unwrap(), 0, completed, false, gains[idx], missions) {
|
||||||
completed_missions.push(m).unwrap();
|
completed_missions.push(m).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,40 +652,67 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
|
|||||||
|
|
||||||
if tutorial_step >= 130 {
|
if tutorial_step >= 130 {
|
||||||
for data in rv.members() {
|
for data in rv.members() {
|
||||||
for chat in CHATS.members() {
|
let character = data["master_character_id"].as_i64().unwrap();
|
||||||
|
let chat_missions = &databases::CHARACTER_CHAT_MISSIONS[character.to_string()];
|
||||||
|
for (step, chat) in CHATS.members().enumerate() {
|
||||||
if chat.as_i64().unwrap() > data["exp"].as_i64().unwrap() {
|
if chat.as_i64().unwrap() > data["exp"].as_i64().unwrap() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if crate::router::chat::add_chat(data["master_character_id"].as_i64().unwrap(), 1, chats) {
|
if crate::router::chat::add_chat(character, 1, chats) {
|
||||||
let mission_id = 1958000 + (get_master_id(data["master_character_id"].as_i64().unwrap()) * CHATS.len() as i64);
|
if let Some(mission_id) = chat_missions[step][1].as_i64() {
|
||||||
items::update_mission_status(mission_id, 0, true, true, 1, missions);
|
items::update_mission_status(mission_id, 0, true, true, 1, missions);
|
||||||
completed_missions.push(mission_id).unwrap();
|
completed_missions.push(mission_id).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user["character_list"] = character_list;
|
user["character_list"] = character_list;
|
||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -> JsonValue {
|
pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -> JsonValue {
|
||||||
|
live_end_ex(req, key, body, skipped, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume_lp=false is for lives whose stamina was already taken up front
|
||||||
|
// (/multi_live/start does that, because its response reports consumed_stamina).
|
||||||
|
// Everything else still scales off lp_used, so the caller injects use_lp.
|
||||||
|
//
|
||||||
|
// update_score_board=false plays the live without recording a score anywhere: neither the
|
||||||
|
// account's own high score for the song nor the per-song board /live/ranking serves. Only
|
||||||
|
// /multi_live/end passes false, and only for a PUBLIC (random matchmaking) room — see the
|
||||||
|
// privacy note there. Everything else about the live is untouched: the clear count, the
|
||||||
|
// max combo, the clear-rate counters, missions and every reward are computed off this
|
||||||
|
// play's own score exactly as they always were.
|
||||||
|
pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool, consume_lp: bool, update_score_board: bool) -> JsonValue {
|
||||||
let mut user2 = userdata::get_acc_home(&key);
|
let mut user2 = userdata::get_acc_home(&key);
|
||||||
let mut user = userdata::get_acc(&key);
|
let mut user = userdata::get_acc(&key);
|
||||||
let mut user_missions = userdata::get_acc_missions(&key);
|
let mut user_missions = userdata::get_acc_missions(&key);
|
||||||
let mut chats = userdata::get_acc_chats(&key);
|
let mut chats = userdata::get_acc_chats(&key);
|
||||||
|
|
||||||
|
// Read once, off the request, with a default each: a client (or a replayed/forged
|
||||||
|
// POST) that omits a field must get a wrong-but-harmless result, not a panicked
|
||||||
|
// worker. /multi_live/end is the reachable path — it forwards a body this handler
|
||||||
|
// never validated — but the solo path gets the same treatment, and for a well-formed
|
||||||
|
// body every one of these is exactly what the old unwrap produced.
|
||||||
|
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);
|
||||||
|
let max_combo = body["live_score"]["max_combo"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
let jp = items::get_region(req.headers());
|
let jp = items::get_region(req.headers());
|
||||||
let first_clear = !skipped
|
let first_clear = !skipped
|
||||||
&& user["tutorial_step"].as_i32().unwrap() >= 130
|
&& user["tutorial_step"].as_i32().unwrap() >= 130
|
||||||
&& get_clear_count(body["master_live_id"].as_i64().unwrap(), &user) == 0;
|
&& get_clear_count(live_id, &user) == 0;
|
||||||
|
|
||||||
let live = if skipped {
|
let live = if skipped {
|
||||||
items::use_item(&object!{
|
items::use_item(&object!{
|
||||||
value: 21000001,
|
value: 21000001,
|
||||||
amount: 1,
|
amount: 1,
|
||||||
consumeType: 4
|
consumeType: 4
|
||||||
}, body["live_boost"].as_i64().unwrap(), &mut user);
|
}, body["live_boost"].as_i64().unwrap_or(0), &mut user);
|
||||||
update_live_data(&mut user, &object!{
|
update_live_data(&mut user, &object!{
|
||||||
master_live_id: body["master_live_id"].clone(),
|
master_live_id: body["master_live_id"].clone(),
|
||||||
level: 0,
|
level: 0,
|
||||||
@@ -617,9 +720,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
|
|||||||
score: 1,
|
score: 1,
|
||||||
max_combo: 1
|
max_combo: 1
|
||||||
}
|
}
|
||||||
}, false)
|
}, false, update_score_board)
|
||||||
} else {
|
} else {
|
||||||
update_live_data(&mut user, &body, true)
|
update_live_data(&mut user, &body, true, update_score_board)
|
||||||
};
|
};
|
||||||
|
|
||||||
//1273009, 1273010, 1273011, 1273012
|
//1273009, 1273010, 1273011, 1273012
|
||||||
@@ -634,21 +737,27 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The account the per-song board is keyed by, or 0 to leave the board alone —
|
||||||
|
// clear_rate::update_live_score's own "no user, no board entry" guard, which is how
|
||||||
|
// /live/retire already counts a play it must not rank. The clear-rate counters still
|
||||||
|
// get the play either way: a public multi live really was played.
|
||||||
|
let board_uid = if update_score_board { user["user"]["id"].as_i64().unwrap() } else { 0 };
|
||||||
|
|
||||||
let missions;
|
let missions;
|
||||||
if skipped {
|
if skipped {
|
||||||
live_completed(body["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), false, live["high_score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap());
|
live_completed(live_id, live["level"].as_i32().unwrap_or(0), false, live["high_score"].as_i64().unwrap_or(0), board_uid);
|
||||||
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user);
|
let clear_count = get_clear_count(live_id, &user);
|
||||||
|
|
||||||
missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), live["high_score"].as_i64().unwrap(), live["max_combo"].as_i64().unwrap(), clear_count, live["level"].as_i64().unwrap(), false, false).unwrap_or(array![]);
|
missions = get_live_mission_completed_ids(&user, live_id, live["high_score"].as_i64().unwrap_or(0), live["max_combo"].as_i64().unwrap_or(0), clear_count, live["level"].as_i64().unwrap_or(0), false, false).unwrap_or(array![]);
|
||||||
} else {
|
} else {
|
||||||
live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), false, body["live_score"]["score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap());
|
live_completed(live_id, level as i32, false, score, board_uid);
|
||||||
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user);
|
let clear_count = get_clear_count(live_id, &user);
|
||||||
|
|
||||||
let is_full_combo = (body["live_score"]["good"].as_i32().unwrap_or(1) + body["live_score"]["bad"].as_i32().unwrap_or(1) + body["live_score"]["miss"].as_i32().unwrap_or(1)) == 0;
|
let is_full_combo = (body["live_score"]["good"].as_i32().unwrap_or(1) + body["live_score"]["bad"].as_i32().unwrap_or(1) + body["live_score"]["miss"].as_i32().unwrap_or(1)) == 0;
|
||||||
|
|
||||||
let is_perfect = is_full_combo && body["live_score"]["great"].as_i32().unwrap_or(1) == 0;
|
let is_perfect = is_full_combo && body["live_score"]["great"].as_i32().unwrap_or(1) == 0;
|
||||||
|
|
||||||
missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), body["live_score"]["score"].as_i64().unwrap(), body["live_score"]["max_combo"].as_i64().unwrap(), clear_count, body["level"].as_i64().unwrap(), is_full_combo, is_perfect).unwrap_or(array![]);
|
missions = get_live_mission_completed_ids(&user, live_id, score, max_combo, clear_count, level, is_full_combo, is_perfect).unwrap_or(array![]);
|
||||||
|
|
||||||
if is_full_combo {
|
if is_full_combo {
|
||||||
if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() {
|
if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() {
|
||||||
@@ -673,13 +782,13 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
|
|||||||
if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() {
|
if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() {
|
||||||
cleared_missions.push(1177001).unwrap();
|
cleared_missions.push(1177001).unwrap();
|
||||||
}
|
}
|
||||||
if is_perfect && body["level"].as_i32().unwrap() == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() {
|
if is_perfect && level == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() {
|
||||||
cleared_missions.push(1177002).unwrap();
|
cleared_missions.push(1177002).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_live_mission_data(&mut user, &object!{
|
update_live_mission_data(&mut user, &object!{
|
||||||
master_live_id: body["master_live_id"].as_i64().unwrap(),
|
master_live_id: live_id,
|
||||||
clear_master_live_mission_ids: missions.clone()
|
clear_master_live_mission_ids: missions.clone()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -687,7 +796,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
|
|||||||
|
|
||||||
let mut reward_list = give_mission_rewards(&mut user, &mut user2, &missions, &mut user_missions, &mut cleared_missions, &mut chats, (lp_used / 10) as i64, jp);
|
let mut reward_list = give_mission_rewards(&mut user, &mut user2, &missions, &mut user_missions, &mut cleared_missions, &mut chats, (lp_used / 10) as i64, jp);
|
||||||
|
|
||||||
|
if consume_lp {
|
||||||
items::lp_modification(&mut user, lp_used as u64, true);
|
items::lp_modification(&mut user, lp_used as u64, true);
|
||||||
|
}
|
||||||
|
|
||||||
items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions);
|
items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions);
|
||||||
|
|
||||||
@@ -743,6 +854,18 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||||
|
// "arcade": true - a credit in the cabinet already paid for this play, so LP
|
||||||
|
// is neither read nor decremented. The seam is the one /multi_live/end uses
|
||||||
|
// (live_end_ex's consume_lp), with use_lp injected as one normal 1x play, so
|
||||||
|
// every reward, the EXP, the bonds, the high score and the clear all record
|
||||||
|
// exactly as they do for a phone player. The flag is only honoured for an
|
||||||
|
// account that really belongs to a cabinet - see arcade::arcade_play_user.
|
||||||
|
if let Some(user_id) = arcade::arcade_play_user(&key, &body) {
|
||||||
|
let body = arcade::live_end_body(&body);
|
||||||
|
let rv = live_end_ex(&req, &key, &body, false, false, true);
|
||||||
|
arcade::record_play(user_id, &body, true);
|
||||||
|
return Api(Some(rv));
|
||||||
|
}
|
||||||
Api(Some(live_end(&req, &key, &body, false)))
|
Api(Some(live_end(&req, &key, &body, false)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +892,7 @@ mod tests {
|
|||||||
master_live_id: live_id,
|
master_live_id: live_id,
|
||||||
level: level,
|
level: level,
|
||||||
live_score: { score: score, max_combo: combo }
|
live_score: { score: score, max_combo: combo }
|
||||||
}, true);
|
}, true, true);
|
||||||
userdata::save_acc(token, user);
|
userdata::save_acc(token, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +900,118 @@ mod tests {
|
|||||||
get_clear_count(live_id, &userdata::get_acc(token))
|
get_clear_count(live_id, &userdata::get_acc(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same clear with the score board switched off, which is what live_end_ex does for
|
||||||
|
// a multi live played in a PUBLIC room. Returns the `live` record the client is
|
||||||
|
// answered with.
|
||||||
|
fn record_unscored_clear(token: &str, live_id: i64, level: i64, score: i64, combo: i64) -> JsonValue {
|
||||||
|
let mut user = userdata::get_acc(token);
|
||||||
|
let live = update_live_data(&mut user, &object!{
|
||||||
|
master_live_id: live_id,
|
||||||
|
level: level,
|
||||||
|
live_score: { score: score, max_combo: combo }
|
||||||
|
}, true, false);
|
||||||
|
userdata::save_acc(token, user);
|
||||||
|
live
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_live(token: &str, live_id: i64) -> JsonValue {
|
||||||
|
userdata::get_acc(token)["live_list"]
|
||||||
|
.members()
|
||||||
|
.find(|l| l["master_live_id"] == live_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(JsonValue::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /multi_live/end's score-board branch, at the write it actually gates. live_end_ex
|
||||||
|
// itself cannot be driven from a test (items::get_region reaches the clap parser, which
|
||||||
|
// rejects the harness's own arguments), so the two halves of the gate are pinned where
|
||||||
|
// they live: the account's own high score here, and the per-song board next door in
|
||||||
|
// clear_rate.
|
||||||
|
//
|
||||||
|
// A PRIVATE room is the unchanged path: it records exactly like a solo live, keep-best.
|
||||||
|
#[test]
|
||||||
|
fn a_private_multi_live_records_its_score_keep_best() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "sb_private_room";
|
||||||
|
register_account(token);
|
||||||
|
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 500000, 320);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000);
|
||||||
|
|
||||||
|
// A better score wins...
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 600000, 340);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000);
|
||||||
|
|
||||||
|
// ...and a worse one never overwrites it.
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 100000, 20);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000);
|
||||||
|
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PUBLIC room is the official behaviour: the play counts, the score does not.
|
||||||
|
#[test]
|
||||||
|
fn a_public_multi_live_leaves_the_high_score_alone() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "sb_public_room";
|
||||||
|
register_account(token);
|
||||||
|
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 500000, 320);
|
||||||
|
|
||||||
|
// A score that would beat the stored one is not recorded, and the answer the client
|
||||||
|
// gets back reports the record that still stands rather than this play's score.
|
||||||
|
let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400);
|
||||||
|
assert_eq!(live["high_score"], 500000);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000);
|
||||||
|
|
||||||
|
// Everything else about the live still lands: the clear counts and the combo is a
|
||||||
|
// real one (the client only ever said HIGH SCORE was not updated).
|
||||||
|
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 2);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["max_combo"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The solo path is untouched by the multi branch: live_end — the only entry /live/end
|
||||||
|
// and /live/skip have — calls live_end_ex with update_score_board hardcoded true, so a
|
||||||
|
// solo live writes exactly what it always did.
|
||||||
|
#[test]
|
||||||
|
fn the_solo_live_path_still_records_its_score() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "sb_solo_path";
|
||||||
|
register_account(token);
|
||||||
|
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 450000, 300);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000);
|
||||||
|
|
||||||
|
// /live/skip's shape: level 0 matches whatever level is stored, its stand-in score
|
||||||
|
// of 1 can never beat the record, and the record is what it answers with.
|
||||||
|
let mut user = userdata::get_acc(token);
|
||||||
|
let live = update_live_data(&mut user, &object!{
|
||||||
|
master_live_id: STOCK_LIVE_ID,
|
||||||
|
level: 0,
|
||||||
|
live_score: { score: 1, max_combo: 1 }
|
||||||
|
}, false, true);
|
||||||
|
userdata::save_acc(token, user);
|
||||||
|
assert_eq!(live["high_score"], 450000);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_public_multi_live_on_a_new_song_records_no_score_at_all() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "sb_public_first_play";
|
||||||
|
register_account(token);
|
||||||
|
|
||||||
|
// The first ever play of the song is a public multi: the song gets a live record
|
||||||
|
// so the clear counts, but there is no high score to show for it.
|
||||||
|
let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400);
|
||||||
|
assert_eq!(live["high_score"], 0);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 0);
|
||||||
|
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 1);
|
||||||
|
|
||||||
|
// A later solo play sets it for real.
|
||||||
|
record_clear(token, STOCK_LIVE_ID, 4, 300000, 100);
|
||||||
|
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 300000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_count_persists_with_custom_songs_disabled() {
|
fn clear_count_persists_with_custom_songs_disabled() {
|
||||||
let _lock = crate::runtime::lock_test_data_path();
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
@@ -849,4 +1084,37 @@ mod tests {
|
|||||||
assert_eq!(asset_gate(stock, "ios", ""), None);
|
assert_eq!(asset_gate(stock, "ios", ""), None);
|
||||||
assert_eq!(asset_gate(stock, "Windows", ""), None);
|
assert_eq!(asset_gate(stock, "Windows", ""), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bond_ids(character: i64) -> Vec<i64> {
|
||||||
|
bond_missions(character).members().map(|m| m[1].as_i64().unwrap()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bond_missions_match_masterdata() {
|
||||||
|
assert_eq!(bond_ids(1001), vec![1158001, 1158040]);
|
||||||
|
// Not contiguous with character order, and previously derived from the
|
||||||
|
// id's last digit: 3010/3011/3012 and 4010/4011 all resolved to another
|
||||||
|
// character's mission.
|
||||||
|
assert_eq!(bond_ids(3010), vec![1158028, 1158067]);
|
||||||
|
assert_eq!(bond_ids(3012), vec![1158030, 1158069]);
|
||||||
|
assert_eq!(bond_ids(4010), vec![1158079, 1158081]);
|
||||||
|
assert_eq!(bond_ids(4011), vec![1158080, 1158082]);
|
||||||
|
|
||||||
|
// Bands with no bond missions must map to nothing rather than collide
|
||||||
|
// with a mu's character: 6xxx is official, 5xxx is the SIF1 card import.
|
||||||
|
assert!(bond_missions(6001).is_empty());
|
||||||
|
assert!(bond_missions(5001).is_empty());
|
||||||
|
assert!(bond_missions(5172).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_missions_align_with_bond_steps() {
|
||||||
|
let chats = &databases::CHARACTER_CHAT_MISSIONS["1001"];
|
||||||
|
assert_eq!(chats.len(), CHATS.len());
|
||||||
|
for (step, threshold) in CHATS.members().enumerate() {
|
||||||
|
assert_eq!(chats[step][0].as_i64(), threshold.as_i64());
|
||||||
|
assert_eq!(chats[step][1].as_i64(), Some(1958001 + step as i64));
|
||||||
|
}
|
||||||
|
assert!(databases::CHARACTER_CHAT_MISSIONS["5001"].is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use jzon::{array, object, JsonValue};
|
|||||||
use actix_web::{web, HttpRequest, Responder};
|
use actix_web::{web, HttpRequest, Responder};
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
|
|
||||||
use crate::router::{global, userdata, items, databases, Body, Login, Session, Api};
|
use crate::router::{global, userdata, items, databases, custom_card, Body, Login, Session, Api};
|
||||||
|
use crate::database::custom_card as custom_card_db;
|
||||||
|
|
||||||
pub fn routes(cfg: &mut web::ServiceConfig) {
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -106,6 +107,85 @@ fn get_random_cards(id: i64, mut count: usize) -> JsonValue {
|
|||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The runtime custom-card banner (lottery id 6900001). The CLIENT synthesizes
|
||||||
|
// its lottery/price/rarity/item masterdata from the catalog; the server only
|
||||||
|
// handles the draw. Cost and rarity structure mirror the baked SIF1-import
|
||||||
|
// banners 6110001-6110004 (lottery_price.csv / lottery_rarity.csv):
|
||||||
|
// price 1 = 11 draws / 3000 free gems, price 2 = 1 draw / 300 free gems
|
||||||
|
// normal roll r1 6800 / r2 2600 / r3 600; multi draws replace one roll with
|
||||||
|
// an ensured r2-or-better at 8125 / 1875
|
||||||
|
// Wire contract for the drawn items (the client synthesizes matching
|
||||||
|
// LotteryItemMst rows): master_lottery_item_id = 690000100 + rarity,
|
||||||
|
// master_lottery_item_number = master_card_id - 150000000
|
||||||
|
const CUSTOM_BANNER_RATIO: &[(i64, i64)] = &[(1, 6800), (2, 2600), (3, 600)];
|
||||||
|
const CUSTOM_BANNER_ENSURED: &[(i64, i64)] = &[(2, 8125), (3, 1875)];
|
||||||
|
|
||||||
|
fn custom_banner_price(price_number: i64) -> JsonValue {
|
||||||
|
match price_number {
|
||||||
|
1 => object!{"masterItemId": 0, "consumeType": 1, "count": 11, "price": 3000},
|
||||||
|
2 => object!{"masterItemId": 0, "consumeType": 1, "count": 1, "price": 300},
|
||||||
|
_ => JsonValue::Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One roll: weighted rarity over the rarities that actually have published +
|
||||||
|
// obtainable cards, then a uniform card within the rarity. None when every
|
||||||
|
// pool is empty
|
||||||
|
fn custom_banner_roll(table: &[(i64, i64)], pools: &[Vec<i64>; 3], rng: &mut rand::rngs::ThreadRng) -> Option<(i64, i64)> {
|
||||||
|
let available: Vec<&(i64, i64)> = table.iter().filter(|(rarity, _)| !pools[(*rarity - 1) as usize].is_empty()).collect();
|
||||||
|
let total: i64 = available.iter().map(|(_, ratio)| ratio).sum();
|
||||||
|
if total <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let roll = rng.random_range(1..=total);
|
||||||
|
let mut cumulative = 0;
|
||||||
|
for (rarity, ratio) in available {
|
||||||
|
cumulative += ratio;
|
||||||
|
if roll <= cumulative {
|
||||||
|
let pool = &pools[(*rarity - 1) as usize];
|
||||||
|
return Some((*rarity, pool[rng.random_range(0..pool.len())]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// The draw, in the same result shape get_random_cards produces so the stock
|
||||||
|
// grant loop consumes it unchanged. Empty when there is nothing obtainable -
|
||||||
|
// the caller bails before charging
|
||||||
|
fn custom_banner_cards(count: usize) -> JsonValue {
|
||||||
|
let pools: [Vec<i64>; 3] = [
|
||||||
|
custom_card_db::obtainable_card_ids(1),
|
||||||
|
custom_card_db::obtainable_card_ids(2),
|
||||||
|
custom_card_db::obtainable_card_ids(3)
|
||||||
|
];
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let mut rv = array![];
|
||||||
|
let mut remaining = count;
|
||||||
|
if count > 1 {
|
||||||
|
// The ensured slot falls back to a normal roll when no r2/r3 exists
|
||||||
|
if let Some((rarity, card)) = custom_banner_roll(CUSTOM_BANNER_ENSURED, &pools, &mut rng)
|
||||||
|
.or_else(|| custom_banner_roll(CUSTOM_BANNER_RATIO, &pools, &mut rng)) {
|
||||||
|
rv.push(object!{
|
||||||
|
"id": card,
|
||||||
|
"master_card_id": card,
|
||||||
|
"master_lottery_item_id": 690_000_100 + rarity,
|
||||||
|
"master_lottery_item_number": card - 150_000_000
|
||||||
|
}).unwrap();
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _ in 0..remaining {
|
||||||
|
let Some((rarity, card)) = custom_banner_roll(CUSTOM_BANNER_RATIO, &pools, &mut rng) else { break; };
|
||||||
|
rv.push(object!{
|
||||||
|
"id": card,
|
||||||
|
"master_card_id": card,
|
||||||
|
"master_lottery_item_id": 690_000_100 + rarity,
|
||||||
|
"master_lottery_item_number": card - 150_000_000
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
fn lottery_day() -> i64 {
|
fn lottery_day() -> i64 {
|
||||||
(global::timestamp() as i64 + 32400) / 86400
|
(global::timestamp() as i64 + 32400) / 86400
|
||||||
}
|
}
|
||||||
@@ -209,7 +289,32 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
if (6_000_000..7_000_000).contains(&lottery_id) && !crate::router::card::client_supports_custom_cards(&req) {
|
if (6_000_000..7_000_000).contains(&lottery_id) && !crate::router::card::client_supports_custom_cards(&req) {
|
||||||
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
|
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
|
||||||
}
|
}
|
||||||
|
// The runtime custom-card banner additionally needs the catalog protocol
|
||||||
|
let is_custom_banner = lottery_id == custom_card::CUSTOM_LOTTERY_ID;
|
||||||
|
if is_custom_banner && (custom_card::disabled() || !custom_card::client_supports(&req)) {
|
||||||
|
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (price, cardstogive, lottery_type, exchange_id) = if is_custom_banner {
|
||||||
|
let price = custom_banner_price(price_number);
|
||||||
|
if price.is_null() {
|
||||||
|
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
|
||||||
|
}
|
||||||
|
let drawn = custom_banner_cards(price["count"].as_usize().unwrap());
|
||||||
|
if drawn.is_empty() {
|
||||||
|
// Nothing published + obtainable: nothing charged, nothing drawn.
|
||||||
|
// The client only synthesizes the banner when the pool is
|
||||||
|
// non-empty, so this is a stale-catalog race, not a normal path
|
||||||
|
return global::api(&req, Some(object!{
|
||||||
|
"lottery_item_list": [],
|
||||||
|
"updated_value_list": {},
|
||||||
|
"gift_list": user2["home"]["gift_list"].clone(),
|
||||||
|
"clear_mission_ids": [],
|
||||||
|
"draw_count_list": []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
(price, drawn, 1, 0)
|
||||||
|
} else {
|
||||||
let lottery = &databases::LOTTERY[lottery_id.to_string()];
|
let lottery = &databases::LOTTERY[lottery_id.to_string()];
|
||||||
let lottery_type = lottery["category"].as_i32().unwrap();
|
let lottery_type = lottery["category"].as_i32().unwrap();
|
||||||
let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0);
|
let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0);
|
||||||
@@ -221,6 +326,9 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
(lottery["masterLotteryPriceId"].as_i64().unwrap_or(lottery_id), lottery["masterLotteryRarityId"].as_i64().unwrap_or(lottery_id))
|
(lottery["masterLotteryPriceId"].as_i64().unwrap_or(lottery_id), lottery["masterLotteryRarityId"].as_i64().unwrap_or(lottery_id))
|
||||||
};
|
};
|
||||||
let price = databases::PRICE[price_id.to_string()][price_number.to_string()].clone();
|
let price = databases::PRICE[price_id.to_string()][price_number.to_string()].clone();
|
||||||
|
let count = price["count"].as_usize().unwrap();
|
||||||
|
(price, get_random_cards(rarity_id, count), lottery_type, exchange_id)
|
||||||
|
};
|
||||||
|
|
||||||
items::use_item(&object!{
|
items::use_item(&object!{
|
||||||
value: price["masterItemId"].clone(),
|
value: price["masterItemId"].clone(),
|
||||||
@@ -228,10 +336,6 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
consumeType: price["consumeType"].clone()
|
consumeType: price["consumeType"].clone()
|
||||||
}, 1, &mut user);
|
}, 1, &mut user);
|
||||||
|
|
||||||
let count = price["count"].as_usize().unwrap();
|
|
||||||
|
|
||||||
let cardstogive = get_random_cards(rarity_id, count);
|
|
||||||
|
|
||||||
let mut new_cards = array![];
|
let mut new_cards = array![];
|
||||||
let mut lottery_list = array![];
|
let mut lottery_list = array![];
|
||||||
|
|
||||||
@@ -295,9 +399,19 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
userdata::save_acc_chats(&key, chats);
|
userdata::save_acc_chats(&key, chats);
|
||||||
userdata::save_acc_missions(&key, missions);
|
userdata::save_acc_missions(&key, missions);
|
||||||
|
|
||||||
|
// An account holding a runtime custom card needs a catalog-fetching
|
||||||
|
// client from now on; start.rs enforces it (monotonic, like the level-2
|
||||||
|
// flag for the baked band)
|
||||||
|
if cardstogive.members().any(|card| custom_card::is_custom_runtime(card["master_card_id"].as_i64().unwrap_or(0))) {
|
||||||
|
userdata::save_protocol_version(&key, custom_card::PROTOCOL_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
global::api(&req, Some(object!{
|
global::api(&req, Some(object!{
|
||||||
"lottery_item_list": lottery_list,
|
"lottery_item_list": lottery_list,
|
||||||
"updated_value_list": {
|
"updated_value_list": {
|
||||||
|
// The draw can charge gems (the custom banners do); without this
|
||||||
|
// the client's balance drifts until the next /api/user pull
|
||||||
|
"gem": user["gem"].clone(),
|
||||||
"card_list": new_cards,
|
"card_list": new_cards,
|
||||||
"item_list": user["item_list"].clone()
|
"item_list": user["item_list"].clone()
|
||||||
},
|
},
|
||||||
@@ -317,6 +431,64 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The runtime banner draws only published + obtainable cards, honors the
|
||||||
|
// ensured slot, and speaks the agreed lottery_item id/number convention
|
||||||
|
#[test]
|
||||||
|
fn runtime_custom_banner_draw() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
crate::router::custom_card::tests::wipe(6001);
|
||||||
|
|
||||||
|
// Nothing obtainable: the draw comes back empty (the route then bails
|
||||||
|
// before charging)
|
||||||
|
assert!(custom_banner_cards(11).is_empty());
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
let drawn = custom_banner_cards(11);
|
||||||
|
assert_eq!(drawn.len(), 11);
|
||||||
|
// The ensured slot is drawn first: rarity 2 or 3
|
||||||
|
let first = drawn[0]["master_card_id"].as_i64().unwrap();
|
||||||
|
assert!(first == r2 || first == r3, "ensured slot drew {}", first);
|
||||||
|
for card in drawn.members() {
|
||||||
|
let id = card["master_card_id"].as_i64().unwrap();
|
||||||
|
assert!(r1_ids.contains(&id) || id == r2 || id == r3, "drew {}", id);
|
||||||
|
let rarity = custom_card_db::get_card(id).unwrap()["rarity"].as_i64().unwrap();
|
||||||
|
// The contract the client synthesizes its LotteryItemMst rows to
|
||||||
|
assert_eq!(card["master_lottery_item_id"].as_i64(), Some(690_000_100 + rarity));
|
||||||
|
assert_eq!(card["master_lottery_item_number"].as_i64(), Some(id - 150_000_000));
|
||||||
|
assert_eq!(card["id"], card["master_card_id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single draws have no ensured slot but still only draw the pool
|
||||||
|
let single = custom_banner_cards(1);
|
||||||
|
assert_eq!(single.len(), 1);
|
||||||
|
|
||||||
|
// Price rows mirror the baked banners; unknown numbers are refused
|
||||||
|
assert_eq!(custom_banner_price(1)["price"].as_i64(), Some(3000));
|
||||||
|
assert_eq!(custom_banner_price(1)["count"].as_i64(), Some(11));
|
||||||
|
assert_eq!(custom_banner_price(2)["price"].as_i64(), Some(300));
|
||||||
|
assert_eq!(custom_banner_price(2)["count"].as_i64(), Some(1));
|
||||||
|
assert!(custom_banner_price(3).is_null());
|
||||||
|
|
||||||
|
crate::router::custom_card::tests::wipe(6001);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_banner_draw() {
|
fn custom_banner_draw() {
|
||||||
for lid in [6110001i64, 6110002, 6110003, 6110004] {
|
for lid in [6110001i64, 6110002, 6110003, 6110004] {
|
||||||
|
|||||||
797
src/router/multi_live.rs
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
// Wire format shared with the C# client rewrite; see docs/multi-live-ws-protocol.md.
|
||||||
|
mod proto;
|
||||||
|
// Lobby/room state machine, WebSocket-free so it can be tested without a socket.
|
||||||
|
mod rooms;
|
||||||
|
// The /multi_live/ws endpoint itself.
|
||||||
|
mod ws;
|
||||||
|
|
||||||
|
use jzon::{object, JsonValue};
|
||||||
|
use actix_web::{web, HttpRequest, Responder};
|
||||||
|
|
||||||
|
use crate::router::{databases, event, event_ranking, global, items, live, userdata, Session, Api};
|
||||||
|
|
||||||
|
// The relay's expiry timers (held slots, empty rooms, dead connections). Started once from
|
||||||
|
// run_server; see ws::start_sweeper for why it is not lazily started by the first upgrade.
|
||||||
|
pub use ws::start_sweeper;
|
||||||
|
|
||||||
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/multi_live")
|
||||||
|
.route("/start", web::post().to(start))
|
||||||
|
.route("/end", web::post().to(end))
|
||||||
|
// The Photon replacement. Note this sits inside the /api scope, so the
|
||||||
|
// handshake goes through webui_fallback and must carry the usual
|
||||||
|
// aoharu-asset-version header like every other game request.
|
||||||
|
.route("/ws", web::get().to(ws::ws))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shock.MULTI_LIVE_END_STATUS, the enum RecvMultiLiveEndRData.is_penalty_miss_ratio
|
||||||
|
// is cast to (MngLiveData.SendMultiLiveEnd).
|
||||||
|
// NONE(0) - normal result, client processes every reward list.
|
||||||
|
// MISS_RATIO_PENALTY_STATUS(1) - MngLiveData bails out right after user/stamina,
|
||||||
|
// LiveScene.OnMultiLiveEnd leaves the room and
|
||||||
|
// returns to the multi-event top. Nothing is awarded.
|
||||||
|
// GREAT_PERFECT_LOW_RATIO_STATUS(2) - rewards still apply, the client only raises
|
||||||
|
// MultiEventLiveResult.IsOpenCautionDialog.
|
||||||
|
const STATUS_NONE: u8 = 0;
|
||||||
|
const STATUS_MISS_RATIO_PENALTY: u8 = 1;
|
||||||
|
const STATUS_GREAT_PERFECT_LOW_RATIO: u8 = 2;
|
||||||
|
|
||||||
|
// Shock.COMMON_CONST.RATIO_DIVISOR. Every ratio in masterdata (event_score._eventPointRatio
|
||||||
|
// / ._eventBoostRatio, live_boost._eventPointRatio, multievent_rankbonus._eventPtBonus,
|
||||||
|
// multievent_card_bonus._pointBonusRatioList) is stored in 1/10000 and divided by this.
|
||||||
|
const RATIO_ONE: i64 = 10000;
|
||||||
|
|
||||||
|
// Extended-protocol revision this feature requires (X-Protocol-Version, the same ladder
|
||||||
|
// card.rs=2 / custom_card.rs=3 use). Older client builds carry incompatible multi
|
||||||
|
// implementations — pre-relay wire framing and pre-rework flows — so every multi_live
|
||||||
|
// surface (start, end, and the WS upgrade in ws.rs) refuses anything below it.
|
||||||
|
// 4 = multi-live over the self-hosted WS relay (permanent co-op).
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
fn protocol_too_old(req: &HttpRequest) -> bool {
|
||||||
|
global::client_protocol_version(req) < PROTOCOL_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
fn const_value(id: &str, default: i64) -> i64 {
|
||||||
|
let raw = &databases::CONST[id]["value"];
|
||||||
|
raw.as_str()
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
.or_else(|| raw.as_i64())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn boost_lp(live_boost: i64) -> i64 {
|
||||||
|
databases::LIVE_BOOST[live_boost.to_string()]["lp"]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or(10 * live_boost)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boost_event_point_ratio(live_boost: i64) -> i64 {
|
||||||
|
databases::LIVE_BOOST[live_boost.to_string()]["eventPointRatio"]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or(RATIO_ONE * live_boost.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MusicLevelMst._fullCombo — the note count both client ratio checks divide by.
|
||||||
|
fn note_count(master_live_id: i64, level: i64) -> Option<i64> {
|
||||||
|
let music_id = databases::LIVE_LIST[master_live_id.to_string()]["masterMusicId"].as_i64()?;
|
||||||
|
let row = &databases::MUSIC_LEVEL[format!("{}_{}", music_id, level)];
|
||||||
|
if row.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let notes = row["fullCombo"].as_i64()?;
|
||||||
|
if notes <= 0 { None } else { Some(notes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aoharu.MultiUtil.IsPenalty / IsHalved, evaluated server-side because
|
||||||
|
// is_penalty_miss_ratio is what the client actually obeys (both MultiUtil helpers
|
||||||
|
// are unreferenced in the client — they were only ever a local preview).
|
||||||
|
//
|
||||||
|
// IsPenalty: (MULTI_PENALTY_MISS_RATIO / 100) * fullCombo <= miss
|
||||||
|
// IsHalved: perfect + great < (MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO / 100) * fullCombo
|
||||||
|
// (skipped for LIVE_LEVEL 1, per `if ((int)level == 1) return false;`)
|
||||||
|
//
|
||||||
|
// MultiUtil.IsHalved reads Perfect + Good, but only because the client-side
|
||||||
|
// MultiLiveResultProtocolData(userId, LiveScore, bool) ctor never assigns Great
|
||||||
|
// (IL2CPP @4769631) — it has no great count to use. The const is named
|
||||||
|
// ..._GREAT_PERFECT_NOTES_MIN_RATIO and the server does receive the full LiveScore,
|
||||||
|
// so perfect + great is used here.
|
||||||
|
//
|
||||||
|
// MULTI_PENALTY_NO_PLAY_MISS_RATIO (5000) is unreachable as a real miss ratio and is
|
||||||
|
// the value the ratio takes when nothing was judged at all: a client that never played
|
||||||
|
// reports 0 misses, which the 70% rule would wave through.
|
||||||
|
//
|
||||||
|
// Both checks mirror MultiUtil's "missing MusicLevelMst row => false" shape.
|
||||||
|
fn multi_live_end_status(body: &JsonValue) -> u8 {
|
||||||
|
let score = &body["live_score"];
|
||||||
|
let notes = match note_count(
|
||||||
|
body["master_live_id"].as_i64().unwrap_or(0),
|
||||||
|
body["level"].as_i64().unwrap_or(0)
|
||||||
|
) {
|
||||||
|
Some(n) => n,
|
||||||
|
None => return STATUS_NONE
|
||||||
|
};
|
||||||
|
|
||||||
|
let perfect = score["perfect"].as_i64().unwrap_or(0);
|
||||||
|
let great = score["great"].as_i64().unwrap_or(0);
|
||||||
|
let good = score["good"].as_i64().unwrap_or(0);
|
||||||
|
let bad = score["bad"].as_i64().unwrap_or(0);
|
||||||
|
let miss = score["miss"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
let penalty_ratio = const_value("MULTI_PENALTY_MISS_RATIO", 70);
|
||||||
|
let no_play_ratio = const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", 5000);
|
||||||
|
|
||||||
|
let judged = perfect + great + good + bad + miss;
|
||||||
|
let penalised = if judged == 0 {
|
||||||
|
no_play_ratio >= penalty_ratio
|
||||||
|
} else {
|
||||||
|
miss * 100 >= penalty_ratio * notes
|
||||||
|
};
|
||||||
|
if penalised {
|
||||||
|
return STATUS_MISS_RATIO_PENALTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if body["level"].as_i64().unwrap_or(0) != 1 {
|
||||||
|
let min_ratio = const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", 50);
|
||||||
|
if (perfect + great) * 100 < min_ratio * notes {
|
||||||
|
return STATUS_GREAT_PERFECT_LOW_RATIO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event-point yield of one multi live, from event_score.csv (Shock.EventScoreMst).
|
||||||
|
//
|
||||||
|
// Neither ew nor the reconstructed client had an existing consumer to copy: EventData
|
||||||
|
// exposes _eventLivePointBase / _eventPointRatio / _eventBoostRatio as EventLiveBasePoint
|
||||||
|
// / EventPointRatio / EventBoostRatio (EventData.cs:134-156) but nothing in the client
|
||||||
|
// ever reads those three properties — the award has always been computed server-side.
|
||||||
|
// The interpretation used here is the one the field names and the client's own ratio
|
||||||
|
// convention dictate, and the one this port is specified against:
|
||||||
|
//
|
||||||
|
// points = _eventLivePointBase
|
||||||
|
// * _eventPointRatio / RATIO_DIVISOR (per-event scaling)
|
||||||
|
// * _eventBoostRatio / RATIO_DIVISOR (per-event boost worth)
|
||||||
|
// * live_boost._eventPointRatio / RATIO_DIVISOR (the boost actually spent)
|
||||||
|
//
|
||||||
|
// The last factor mirrors LiveBoostMst.GetEventPointRatio (LiveBoostMst.cs:131-137),
|
||||||
|
// which is `_eventPointRatio / RATIO_DIVISOR` — the boost level itself for the stock
|
||||||
|
// table (boost 3 -> 30000/10000 -> 3).
|
||||||
|
//
|
||||||
|
// All four multi events (108/111/115/119) carry base 10, pointRatio 10000, boostRatio
|
||||||
|
// 35000, so one boost-1 multi live is worth 35 points.
|
||||||
|
//
|
||||||
|
// Division is deferred to the end so the x3.5 boost ratio does not truncate to x3 the
|
||||||
|
// way the client's integer GetEventPointRatio would.
|
||||||
|
fn event_live_points(event_id: u32, live_boost: i64) -> i64 {
|
||||||
|
let row = &databases::EVENT_SCORE[event_id.to_string()];
|
||||||
|
if row.is_empty() {
|
||||||
|
// Should be unreachable: event_score.csv has a row for every event, including
|
||||||
|
// all four multi events. An event with no row is worth nothing rather than
|
||||||
|
// silently falling back to an invented constant.
|
||||||
|
println!("multi_live: no event_score row for event {event_id}, awarding 0 event points");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let base = row["eventLivePointBase"].as_i64().unwrap_or(0);
|
||||||
|
let point_ratio = row["eventPointRatio"].as_i64().unwrap_or(0);
|
||||||
|
let boost_ratio = row["eventBoostRatio"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
base * point_ratio * boost_ratio * boost_event_point_ratio(live_boost)
|
||||||
|
/ (RATIO_ONE * RATIO_ONE * RATIO_ONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stamina this live actually cost, as recorded by /multi_live/start. This is what
|
||||||
|
// live_end_ex scales every reward off, and it is deliberately read without reference to
|
||||||
|
// the backing event: a closed event suppresses scoring, never rewards.
|
||||||
|
fn recorded_lp(started: Option<&JsonValue>) -> i64 {
|
||||||
|
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
|
||||||
|
started
|
||||||
|
.and_then(|s| s["use_lp"].as_i64())
|
||||||
|
.unwrap_or_else(|| boost_lp(live_boost))
|
||||||
|
.max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event a multi live should actually score against, or None when it should score
|
||||||
|
// against nothing.
|
||||||
|
//
|
||||||
|
// Multi is a permanent feature here, entered from a client-side button rather than from
|
||||||
|
// a live event, so the client faithfully sends the backing event id (108) even though
|
||||||
|
// that event is closed and stays closed by choice. Awarding against a closed event would
|
||||||
|
// write event points and ranking rows for a season that is not running, so the whole
|
||||||
|
// event-point path stays dormant — and lights up on its own, with no further change
|
||||||
|
// here, if an event is ever actually opened.
|
||||||
|
//
|
||||||
|
// Nothing else about the live is affected: rewards, EXP, bond, missions and the response
|
||||||
|
// shape all come out of live_end_ex exactly as they do for an in-session event.
|
||||||
|
fn scoring_event(started: Option<&JsonValue>, now: u64) -> Option<u32> {
|
||||||
|
started
|
||||||
|
.and_then(|s| s["master_event_id"].as_u32())
|
||||||
|
.filter(|id| *id != 0)
|
||||||
|
.filter(|id| event::is_in_session(*id, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole award for one multi live: the event_score yield, the finishing-position
|
||||||
|
// bonus from multievent_rankbonus, and the GREAT_PERFECT_LOW_RATIO halving.
|
||||||
|
fn multi_event_points(event_id: u32, live_boost: i64, players: i64, live_rank: i64, status: u8) -> i64 {
|
||||||
|
let mut points = event_live_points(event_id, live_boost);
|
||||||
|
points = points * (RATIO_ONE + rank_bonus(players, live_rank)) / RATIO_ONE;
|
||||||
|
if status == STATUS_GREAT_PERFECT_LOW_RATIO {
|
||||||
|
// MultiUtil calls this state "halved" — the caution dialog goes with a
|
||||||
|
// reduced yield, not a forfeited one.
|
||||||
|
points /= 2;
|
||||||
|
}
|
||||||
|
points
|
||||||
|
}
|
||||||
|
|
||||||
|
// multievent_rankbonus._eventPtBonus for this party size / finishing position.
|
||||||
|
// The table only covers 2-4 players with liveRank <= playerCount; anything outside
|
||||||
|
// it (a solo room, a rank the table has no row for) simply earns no bonus.
|
||||||
|
fn rank_bonus(player_count: i64, live_rank: i64) -> i64 {
|
||||||
|
databases::MULTIEVENT_RANK_BONUS[format!("{}_{}", player_count, live_rank)]["eventPtBonus"]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveScene.MultiTask4 builds other_live_score_list from MultiPlayManager.AllPlayers,
|
||||||
|
// so the poster is already one of its entries. Fall back to len + 1 if a caller ever
|
||||||
|
// sends a list that genuinely excludes itself.
|
||||||
|
fn player_count(body: &JsonValue, user_id: i64) -> i64 {
|
||||||
|
let list = &body["other_live_score_list"];
|
||||||
|
let contains_self = list
|
||||||
|
.members()
|
||||||
|
.any(|s| s["user_id"].as_i64() == Some(user_id));
|
||||||
|
let len = list.len() as i64;
|
||||||
|
if contains_self { len } else { len + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every field of Shock.RecvMultiLiveEndRData, so the penalty path still deserialises
|
||||||
|
// cleanly. The client reads user/stamina and then returns on status 1, but Notify()
|
||||||
|
// runs over the whole payload first.
|
||||||
|
fn barren_response(user: &JsonValue, status: u8) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
"is_penalty_miss_ratio": status,
|
||||||
|
"gem": user["gem"].clone(),
|
||||||
|
"clear_master_live_mission_ids": [],
|
||||||
|
"user": user["user"].clone(),
|
||||||
|
"stamina": user["stamina"].clone(),
|
||||||
|
"character_list": [],
|
||||||
|
"card_list": [],
|
||||||
|
"card_sub_list": [],
|
||||||
|
"item_list": [],
|
||||||
|
"point_list": [],
|
||||||
|
"group_list": [],
|
||||||
|
"reward_list": [],
|
||||||
|
"gift_list": [],
|
||||||
|
"clear_mission_ids": [],
|
||||||
|
"event_point_list": user["event_point_list"].clone(),
|
||||||
|
"event_point_reward_list": [],
|
||||||
|
"ranking_change": [],
|
||||||
|
"music_mission_reward_list": [],
|
||||||
|
"event_ranking_data": {
|
||||||
|
"event_point_rank": 0,
|
||||||
|
"next_reward_rank_point": 0,
|
||||||
|
"event_score_rank": 0,
|
||||||
|
"next_reward_rank_score": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike /live/start, a multi live pays its stamina up front: the response reports
|
||||||
|
// consumed_stamina and the client never sends a boost with /multi_live/end. The
|
||||||
|
// amount actually taken is stashed on the recorded start payload so /multi_live/end
|
||||||
|
// can scale rewards off it without charging for it twice.
|
||||||
|
async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Responder {
|
||||||
|
// Older clients speak an incompatible multi — refuse before touching any state.
|
||||||
|
if protocol_too_old(&req) {
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
|
||||||
|
// 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
|
||||||
|
// — event must start a live exactly like an open one. Stamina comes off live_boost
|
||||||
|
// alone, so nothing on this path depends on the event being in session.
|
||||||
|
let live_boost = body["live_boost"].as_i64().unwrap_or(0);
|
||||||
|
let lp = boost_lp(live_boost).max(0);
|
||||||
|
|
||||||
|
let mut user = userdata::get_acc(&key);
|
||||||
|
// Settle regen first so the balance clamped against below is current.
|
||||||
|
items::lp_modification(&mut user, 0, true);
|
||||||
|
let available = user["stamina"]["stamina"].as_u64().unwrap_or(0);
|
||||||
|
// Clamped, not refused, and that IS the intended answer.
|
||||||
|
//
|
||||||
|
// The client gates on stamina at the entry to matching, never at the POST:
|
||||||
|
// MultiSelectionView.RoomCreation / OpenRoomSearchDialog and MultiRestart.RestartLive
|
||||||
|
// all run StaminaUtils.UseStaminaValue and divert to StaminaChargeScene when it says
|
||||||
|
// the balance is short, while MultiEventMatchingScene.ChangeScene — which is what
|
||||||
|
// actually calls SendMultiLiveStart, and does so off the host's _MoveScene RPC —
|
||||||
|
// computes the cost and drops the "insufficient" flag on the floor. Stamina only ever
|
||||||
|
// goes up between the gate and the POST (regen; the boost selector lives on the gated
|
||||||
|
// panels), so an honest client cannot arrive here short, and the official server was
|
||||||
|
// never asked what to do about it.
|
||||||
|
//
|
||||||
|
// So this is the unreachable-in-practice branch, and taking what is there is the
|
||||||
|
// benign resolution: the live still starts (a party of four must not be broken up by
|
||||||
|
// one member's balance), consumed_stamina reports what was really taken, and because
|
||||||
|
// every reward scales off that same recorded use_lp — see recorded_lp and live_end_ex —
|
||||||
|
// a short live pays out in proportion. Refusing would have to fail a live the other
|
||||||
|
// three players are already committed to; charging the full cost would mean inventing
|
||||||
|
// negative stamina.
|
||||||
|
let consumed = (lp as u64).min(available);
|
||||||
|
items::lp_modification(&mut user, consumed, true);
|
||||||
|
userdata::save_acc(&key, user);
|
||||||
|
|
||||||
|
body["use_lp"] = consumed.into();
|
||||||
|
live::start_live(&key, &body);
|
||||||
|
|
||||||
|
Api(Some(object!{
|
||||||
|
"consumed_stamina": consumed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
||||||
|
if protocol_too_old(&req) {
|
||||||
|
return Api(None);
|
||||||
|
}
|
||||||
|
// Loaded once and reused by every path that answers without playing the live; the
|
||||||
|
// live itself re-reads it, because live_end_ex saves the account.
|
||||||
|
let account = userdata::get_acc(&key);
|
||||||
|
// The user's own clock, so a time-travelling account sees the same event window the
|
||||||
|
// rest of the game shows it.
|
||||||
|
let uid = account["user"]["id"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
// A body with no live id is answered like a duplicate: it cannot name a start record
|
||||||
|
// (matching one on a null id would let it claim a record started with the same
|
||||||
|
// malformed body) and nothing downstream can score it.
|
||||||
|
if body["master_live_id"].as_i64().is_none() {
|
||||||
|
println!("multi_live/end: uid {} posted no master_live_id — nothing to end", uid);
|
||||||
|
return Api(Some(barren_response(&account, STATUS_NONE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The started-live record is what makes an end legitimate, and it is CLAIMED here:
|
||||||
|
// take_started_live returns it and removes it in one transaction, before anything is
|
||||||
|
// awarded, so of N ends arriving together exactly one can proceed. The consumption
|
||||||
|
// used to be a side effect of get_end_live_deck_id deep inside live_end_ex — which
|
||||||
|
// only fired when the record carried a numeric deck_slot, and fired long after the
|
||||||
|
// award had been decided, so a re-POST could be paid twice over.
|
||||||
|
//
|
||||||
|
// Failing the take means this live was already ended: two clients signed in to the
|
||||||
|
// SAME account both POST /multi_live/end for the one shared record, or the client
|
||||||
|
// retried a request whose response it never saw. Granting again would double-count the
|
||||||
|
// clear, the clear-rate counter, the play-count mission and the flat 17001001 drop.
|
||||||
|
// So the duplicate is answered from current state: a well-formed result the client can
|
||||||
|
// display, awarding nothing.
|
||||||
|
let started = live::take_started_live(&key, &body);
|
||||||
|
let started = started.as_ref();
|
||||||
|
if started.is_none() {
|
||||||
|
println!("multi_live/end: uid {} has no start record to spend — answering barren", uid);
|
||||||
|
return Api(Some(barren_response(&account, STATUS_NONE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
|
||||||
|
let lp_used = recorded_lp(started);
|
||||||
|
let event_id = scoring_event(started, global::set_time(global::timestamp(), uid, false));
|
||||||
|
|
||||||
|
let status = multi_live_end_status(&body);
|
||||||
|
println!(
|
||||||
|
"multi_live/end: uid {} score {} miss-ratio status {} ({})",
|
||||||
|
uid,
|
||||||
|
body["live_score"]["score"].as_i64().unwrap_or(-1),
|
||||||
|
status,
|
||||||
|
match status {
|
||||||
|
STATUS_MISS_RATIO_PENALTY => "PENALTY — results voided",
|
||||||
|
2 => "great/perfect low — points halved",
|
||||||
|
_ => "ok",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if status == STATUS_MISS_RATIO_PENALTY {
|
||||||
|
// The client discards the result and leaves the room, so nothing is granted. The
|
||||||
|
// record is already gone (claimed above), so the live is not left hanging until it
|
||||||
|
// expires either — and a re-POST of the same penalised result lands on the
|
||||||
|
// no-record branch rather than back here.
|
||||||
|
return Api(Some(barren_response(&account, status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// /multi_live/end carries neither live_boost nor deck_slot; use_lp is what
|
||||||
|
// live_end scales exp / gold / bond / mission rewards off.
|
||||||
|
let mut end_body = body.clone();
|
||||||
|
end_body["use_lp"] = lp_used.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 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.
|
||||||
|
rv["card_list"] = jzon::array![];
|
||||||
|
rv["card_sub_list"] = jzon::array![];
|
||||||
|
rv["group_list"] = jzon::array![];
|
||||||
|
rv["music_mission_reward_list"] = jzon::array![];
|
||||||
|
// MngLiveData.SendMultiLiveEnd dereferences event_ranking_data unconditionally,
|
||||||
|
// so it must be an object even when this live belongs to no event.
|
||||||
|
rv["event_ranking_data"] = object!{
|
||||||
|
"event_point_rank": 0,
|
||||||
|
"next_reward_rank_point": 0,
|
||||||
|
"event_score_rank": 0,
|
||||||
|
"next_reward_rank_score": 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(event_id) = event_id {
|
||||||
|
// live_end already saved the account; re-read it before touching event points.
|
||||||
|
let mut user = userdata::get_acc(&key);
|
||||||
|
let user_id = user["user"]["id"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
let players = player_count(&body, user_id);
|
||||||
|
let live_rank = body["multi_live_rank"].as_i64().unwrap_or(0);
|
||||||
|
|
||||||
|
let points = multi_event_points(event_id, live_boost, players, live_rank, status);
|
||||||
|
|
||||||
|
event::give_event_points(event_id, points, &mut user);
|
||||||
|
userdata::save_acc(&key, user.clone());
|
||||||
|
|
||||||
|
let total = event::get_points(event_id, &user);
|
||||||
|
event_ranking::live_completed(event_id, user_id, total, 0);
|
||||||
|
let rank = event::get_rank(event_id, user_id as u64);
|
||||||
|
|
||||||
|
let mut event = event::get_event_data(&key, event_id);
|
||||||
|
event["point_ranking"]["point"] = total.into();
|
||||||
|
event["point_ranking"]["rank"] = rank.into();
|
||||||
|
event::save_event_data(&key, event_id, event);
|
||||||
|
|
||||||
|
rv["event_point_list"] = user["event_point_list"].clone();
|
||||||
|
rv["event_ranking_data"] = object!{
|
||||||
|
"event_point_rank": rank,
|
||||||
|
"next_reward_rank_point": 0,
|
||||||
|
"event_score_rank": rank,
|
||||||
|
"next_reward_rank_score": 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Api(Some(rv))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use super::proto::{ClientMsg, Map, Value};
|
||||||
|
use jzon::array;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
const STOCK_LIVE_ID: i64 = 1100101;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_consts_resolve_from_masterdata() {
|
||||||
|
assert_eq!(const_value("MULTI_PENALTY_MISS_RATIO", -1), 70);
|
||||||
|
assert_eq!(const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", -1), 5000);
|
||||||
|
assert_eq!(const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", -1), 50);
|
||||||
|
assert_eq!(const_value("NOT_A_CONST", -1), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boost_costs_come_from_live_boost() {
|
||||||
|
assert_eq!(boost_lp(1), 10);
|
||||||
|
assert_eq!(boost_lp(10), 100);
|
||||||
|
assert_eq!(boost_event_point_ratio(1), RATIO_ONE);
|
||||||
|
assert_eq!(boost_event_point_ratio(3), 3 * RATIO_ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four multi events from multievent_setting.csv, all sharing one event_score row
|
||||||
|
// shape: _eventLivePointBase 10, _eventPointRatio 10000, _eventBoostRatio 35000.
|
||||||
|
const MULTI_EVENTS: [u32; 4] = [108, 111, 115, 119];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_score_rows_back_every_multi_event() {
|
||||||
|
for event_id in MULTI_EVENTS {
|
||||||
|
let row = &databases::EVENT_SCORE[event_id.to_string()];
|
||||||
|
assert!(!row.is_empty(), "event_score row missing for event {event_id}");
|
||||||
|
assert_eq!(row["eventLivePointBase"].as_i64(), Some(10));
|
||||||
|
assert_eq!(row["eventPointRatio"].as_i64(), Some(RATIO_ONE));
|
||||||
|
assert_eq!(row["eventBoostRatio"].as_i64(), Some(35000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_live_points_derive_from_event_score() {
|
||||||
|
// 10 * (10000/10000) * (35000/10000) * boost — and the x3.5 must not truncate
|
||||||
|
// to x3 on the way through.
|
||||||
|
assert_eq!(event_live_points(108, 1), 35);
|
||||||
|
assert_eq!(event_live_points(108, 2), 70);
|
||||||
|
assert_eq!(event_live_points(108, 10), 350);
|
||||||
|
for event_id in MULTI_EVENTS {
|
||||||
|
assert_eq!(event_live_points(event_id, 1), 35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_live_points_fall_back_to_zero_without_a_row() {
|
||||||
|
assert!(databases::EVENT_SCORE["99999"].is_empty());
|
||||||
|
assert_eq!(event_live_points(99999, 1), 0);
|
||||||
|
assert_eq!(multi_event_points(99999, 1, 4, 1, STATUS_NONE), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_event_points_layer_rank_bonus_and_halving() {
|
||||||
|
// 1st of 4 is +30% (multievent_rankbonus 4/1 = 3000).
|
||||||
|
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_NONE), 45);
|
||||||
|
// Last place earns the flat yield.
|
||||||
|
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
|
||||||
|
// The caution state halves whatever the bonus produced.
|
||||||
|
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_GREAT_PERFECT_LOW_RATIO), 22);
|
||||||
|
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_GREAT_PERFECT_LOW_RATIO), 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release_label 223061504, event 108's window: 2023/06/19 05:00:00 - 2023/06/28 04:59:59.
|
||||||
|
fn during_multi_event_108() -> u64 {
|
||||||
|
global::parse_datetime("2023/06/20 12:00:00").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masterdata_datetimes_round_trip() {
|
||||||
|
let t = global::parse_datetime("2023/06/19 5:00:00").unwrap();
|
||||||
|
assert_eq!(global::format_datetime(t), "2023-06-19 05:00:00");
|
||||||
|
// A bare date is midnight, and the blank cells the evergreen label uses parse
|
||||||
|
// to nothing rather than to the epoch.
|
||||||
|
assert_eq!(
|
||||||
|
global::parse_datetime("2023/06/19"),
|
||||||
|
global::parse_datetime("2023/06/19 0:00:00")
|
||||||
|
);
|
||||||
|
assert_eq!(global::parse_datetime(""), None);
|
||||||
|
assert_eq!(global::parse_datetime("null"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_multi_backing_event_is_closed_by_design() {
|
||||||
|
// The evergreen label has no window at all and is always open.
|
||||||
|
assert!(event::release_label_is_open(1, during_multi_event_108()));
|
||||||
|
|
||||||
|
// Event 108 is in session only inside its own long-past label window...
|
||||||
|
assert!(event::is_in_session(108, during_multi_event_108()));
|
||||||
|
// ...and is closed now, which is the permanent-feature state multi runs in.
|
||||||
|
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
|
||||||
|
// whose clap parser chokes on the test-harness filter argument.
|
||||||
|
let now = global::timestamp();
|
||||||
|
assert!(!event::is_in_session(108, now), "event 108 must stay closed");
|
||||||
|
for event_id in MULTI_EVENTS {
|
||||||
|
assert!(!event::is_in_session(event_id, now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closed_backing_event_scores_against_nothing() {
|
||||||
|
let started = object!{ master_event_id: 108, live_boost: 1 };
|
||||||
|
|
||||||
|
// Closed today: the whole event-point path is skipped.
|
||||||
|
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
|
||||||
|
// whose clap parser chokes on the test-harness filter argument.
|
||||||
|
let now = global::timestamp();
|
||||||
|
assert_eq!(scoring_event(Some(&started), now), None);
|
||||||
|
|
||||||
|
// But the gate is a window test, not a hardcoded off switch — open the event
|
||||||
|
// and scoring resumes on its own.
|
||||||
|
assert_eq!(scoring_event(Some(&started), during_multi_event_108()), Some(108));
|
||||||
|
|
||||||
|
// A live with no backing event at all, and a missing start record, score nothing.
|
||||||
|
assert_eq!(scoring_event(Some(&object!{ master_event_id: 0 }), during_multi_event_108()), None);
|
||||||
|
assert_eq!(scoring_event(None, during_multi_event_108()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The end-to-end path cannot be exercised here: live_end_ex calls items::get_region,
|
||||||
|
// which reaches crate::get_args(), whose clap parser rejects the test harness's own
|
||||||
|
// filter argument. (live.rs's tests avoid live_end for the same reason.) What is
|
||||||
|
// testable, and what actually matters, is that the reward input is derived with no
|
||||||
|
// reference to the event window — so a closed event can only ever suppress scoring.
|
||||||
|
#[test]
|
||||||
|
fn a_closed_event_still_pays_its_normal_rewards() {
|
||||||
|
let started = object!{ master_event_id: 108, live_boost: 1, use_lp: 10, deck_slot: 2 };
|
||||||
|
let open = during_multi_event_108();
|
||||||
|
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
|
||||||
|
// whose clap parser chokes on the test-harness filter argument.
|
||||||
|
let closed = global::timestamp();
|
||||||
|
|
||||||
|
// The event gate is the only thing that moves between the two.
|
||||||
|
assert_eq!(scoring_event(Some(&started), open), Some(108));
|
||||||
|
assert_eq!(scoring_event(Some(&started), closed), None);
|
||||||
|
|
||||||
|
// The reward input is identical either way, and identical to what an eventless
|
||||||
|
// live would get for the same boost.
|
||||||
|
assert_eq!(recorded_lp(Some(&started)), 10);
|
||||||
|
assert_eq!(recorded_lp(Some(&object!{ live_boost: 1, use_lp: 10 })), 10);
|
||||||
|
// A start record with no recorded charge still falls back to the boost's cost.
|
||||||
|
assert_eq!(recorded_lp(Some(&object!{ master_event_id: 108, live_boost: 3 })), 30);
|
||||||
|
assert_eq!(recorded_lp(None), 0);
|
||||||
|
|
||||||
|
// And a suppressed event awards nothing, where an open one would pay 35.
|
||||||
|
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- duplicate protection (the claim that gates the award) ----------------------
|
||||||
|
//
|
||||||
|
// /multi_live/end awards if and only if take_started_live hands it the record, and the
|
||||||
|
// record can be handed out exactly once. These drive that claim directly: the handler
|
||||||
|
// itself cannot be called from a test (live_end_ex reaches items::get_region, which
|
||||||
|
// reaches crate::get_args(), whose clap parser rejects the harness's own arguments).
|
||||||
|
|
||||||
|
// Two clients signed in to one account share a single started-live record, so the
|
||||||
|
// second /multi_live/end arrives after the first consumed it. The handler answers that
|
||||||
|
// from current state and grants nothing; this pins the state machine it keys off.
|
||||||
|
#[test]
|
||||||
|
fn a_second_end_for_one_session_finds_no_record_to_spend() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "multi_duplicate_end";
|
||||||
|
|
||||||
|
let start_body = object!{
|
||||||
|
master_live_id: STOCK_LIVE_ID,
|
||||||
|
level: 4,
|
||||||
|
deck_slot: 1,
|
||||||
|
live_boost: 1,
|
||||||
|
master_event_id: 108,
|
||||||
|
use_lp: 10
|
||||||
|
};
|
||||||
|
live::start_live(token, &start_body);
|
||||||
|
|
||||||
|
// First end: the record is there and pays for the live, and claiming it is what
|
||||||
|
// the handler does BEFORE it awards anything.
|
||||||
|
let started = live::take_started_live(token, &start_body);
|
||||||
|
assert!(started.is_some());
|
||||||
|
assert_eq!(recorded_lp(started.as_ref()), 10);
|
||||||
|
|
||||||
|
// Second end: nothing left to spend, which is the duplicate signal the handler
|
||||||
|
// short-circuits on, and the reward scale is zero even if it did not.
|
||||||
|
let again = live::take_started_live(token, &start_body);
|
||||||
|
assert!(again.is_none(), "the record must not survive the first end");
|
||||||
|
assert_eq!(recorded_lp(again.as_ref()), 0);
|
||||||
|
assert_eq!(scoring_event(again.as_ref(), during_multi_event_108()), None);
|
||||||
|
// And it stays gone however many times the client re-POSTs.
|
||||||
|
assert!(live::take_started_live(token, &start_body).is_none());
|
||||||
|
assert!(live::get_started_live(token, &start_body).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consumption used to hide inside get_end_live_deck_id, behind
|
||||||
|
// `record["deck_slot"].as_i32()?` — a start whose body carried no numeric deck_slot
|
||||||
|
// left the record in place, so every re-POST awarded again. Claiming is now about the
|
||||||
|
// record's existence and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn a_start_with_no_deck_slot_is_still_consumed_by_the_first_end() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "multi_no_deck_slot";
|
||||||
|
|
||||||
|
for start_body in [
|
||||||
|
// No deck_slot at all...
|
||||||
|
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10 },
|
||||||
|
// ...and one that is present but not a number.
|
||||||
|
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10, deck_slot: "1" }
|
||||||
|
] {
|
||||||
|
live::start_live(token, &start_body);
|
||||||
|
let first = live::take_started_live(token, &start_body);
|
||||||
|
assert!(first.is_some(), "the record must be claimable without a deck_slot");
|
||||||
|
assert_eq!(recorded_lp(first.as_ref()), 10);
|
||||||
|
|
||||||
|
let second = live::take_started_live(token, &start_body);
|
||||||
|
assert!(second.is_none(), "a re-POST must find nothing left to award off");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two ends landing together — the normal case for one account signed in twice, and
|
||||||
|
// the one the old shape got wrong: both read the record, both passed the guard, both
|
||||||
|
// awarded. The claim is a single transaction, so exactly one of any number of racing
|
||||||
|
// ends can proceed.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_ends_claim_the_record_exactly_once() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let token = "multi_concurrent_end";
|
||||||
|
|
||||||
|
let start_body = object!{
|
||||||
|
master_live_id: STOCK_LIVE_ID,
|
||||||
|
level: 4,
|
||||||
|
deck_slot: 1,
|
||||||
|
live_boost: 1,
|
||||||
|
master_event_id: 108,
|
||||||
|
use_lp: 10
|
||||||
|
};
|
||||||
|
// Create the account before the threads start: the claim itself is atomic, the
|
||||||
|
// lazy account creation behind it is not, and that is not what is under test.
|
||||||
|
live::start_live(token, &start_body);
|
||||||
|
|
||||||
|
let claims = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
std::thread::scope(|s| {
|
||||||
|
for _ in 0..8 {
|
||||||
|
s.spawn(|| {
|
||||||
|
if live::take_started_live(token, &start_body).is_some() {
|
||||||
|
claims.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
claims.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"exactly one of eight simultaneous ends may award"
|
||||||
|
);
|
||||||
|
assert!(live::get_started_live(token, &start_body).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_bonus_matches_multievent_rankbonus() {
|
||||||
|
assert_eq!(rank_bonus(4, 1), 3000);
|
||||||
|
assert_eq!(rank_bonus(4, 4), 0);
|
||||||
|
assert_eq!(rank_bonus(2, 1), 1000);
|
||||||
|
// Rows the table does not cover earn nothing rather than panicking.
|
||||||
|
assert_eq!(rank_bonus(1, 1), 0);
|
||||||
|
assert_eq!(rank_bonus(4, 9), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score(perfect: i64, great: i64, good: i64, bad: i64, miss: i64) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
master_live_id: STOCK_LIVE_ID,
|
||||||
|
level: 4,
|
||||||
|
live_score: {
|
||||||
|
perfect: perfect, great: great, good: good, bad: bad, miss: miss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_status_follows_multiutil_ratios() {
|
||||||
|
let notes = note_count(STOCK_LIVE_ID, 4).expect("music_level row");
|
||||||
|
|
||||||
|
// A clean full combo is normal.
|
||||||
|
assert_eq!(multi_live_end_status(&score(notes, 0, 0, 0, 0)), STATUS_NONE);
|
||||||
|
|
||||||
|
// 70% or more of the notes missed trips the penalty.
|
||||||
|
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, notes)), STATUS_MISS_RATIO_PENALTY);
|
||||||
|
|
||||||
|
// Nothing judged at all is the "no play" case the 70% rule would wave through.
|
||||||
|
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, 0)), STATUS_MISS_RATIO_PENALTY);
|
||||||
|
|
||||||
|
// Under half the notes as perfect/great, but few enough misses to avoid the
|
||||||
|
// penalty, is the caution ("halved") state.
|
||||||
|
let goods = notes - (notes / 4) - 1;
|
||||||
|
assert_eq!(
|
||||||
|
multi_live_end_status(&score(notes / 4, 0, goods, 0, 1)),
|
||||||
|
STATUS_GREAT_PERFECT_LOW_RATIO
|
||||||
|
);
|
||||||
|
|
||||||
|
// LIVE_LEVEL 1 is exempt from the great/perfect check.
|
||||||
|
let mut beginner = score(0, 0, 0, 0, 0);
|
||||||
|
beginner["level"] = 1.into();
|
||||||
|
beginner["live_score"]["good"] = note_count(STOCK_LIVE_ID, 1).unwrap().into();
|
||||||
|
assert_eq!(multi_live_end_status(&beginner), STATUS_NONE);
|
||||||
|
|
||||||
|
// A live with no MusicLevelMst row never penalises, like MultiUtil.
|
||||||
|
let mut unknown = score(0, 0, 0, 0, 0);
|
||||||
|
unknown["master_live_id"] = 999999999i64.into();
|
||||||
|
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_count_does_not_double_count_the_poster() {
|
||||||
|
let body = object!{
|
||||||
|
other_live_score_list: array![
|
||||||
|
object!{ user_id: 7 },
|
||||||
|
object!{ user_id: 8 },
|
||||||
|
object!{ user_id: 9 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
// LiveScene includes the poster in the list.
|
||||||
|
assert_eq!(player_count(&body, 8), 3);
|
||||||
|
// A list that omits the poster still yields the real party size.
|
||||||
|
assert_eq!(player_count(&body, 42), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
970
src/router/multi_live/proto.rs
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
// Wire format for the multi-live WebSocket relay, per docs/multi-live-ws-protocol.md.
|
||||||
|
//
|
||||||
|
// The C# client rewrite encodes/decodes the exact same bytes, so this module is a
|
||||||
|
// straight transcription of the spec tables rather than idiomatic Rust: every field is
|
||||||
|
// written in declaration order, all integers are little-endian, strings are UTF-8 behind
|
||||||
|
// a u16 length, and counts are u8. Nothing here is serde-driven - the type tags mirror
|
||||||
|
// Photon's Hashtable boxing (an Int32 must arrive as Int32 or GetCurrentRoomEventId's
|
||||||
|
// exact `is int` check fails), and a derive would hide that.
|
||||||
|
//
|
||||||
|
// Both directions are implemented in both halves (encode + decode for client messages
|
||||||
|
// AND for server messages) even though the relay only ever decodes the former and
|
||||||
|
// encodes the latter. The unused halves are what the round-trip tests exercise, and they
|
||||||
|
// double as the reference the C# side is written against.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Value tags (see "Value encoding" in the spec).
|
||||||
|
pub const TAG_NULL: u8 = 0;
|
||||||
|
pub const TAG_INT: u8 = 1;
|
||||||
|
pub const TAG_STRING: u8 = 2;
|
||||||
|
pub const TAG_BOOL: u8 = 3;
|
||||||
|
pub const TAG_DOUBLE: u8 = 4;
|
||||||
|
|
||||||
|
// Client -> server opcodes.
|
||||||
|
pub const OP_AUTH: u8 = 1;
|
||||||
|
pub const OP_JOIN_LOBBY: u8 = 2;
|
||||||
|
pub const OP_LEAVE_LOBBY: u8 = 3;
|
||||||
|
pub const OP_CREATE_ROOM: u8 = 4;
|
||||||
|
pub const OP_JOIN_ROOM: u8 = 5;
|
||||||
|
pub const OP_JOIN_RANDOM: u8 = 6;
|
||||||
|
pub const OP_LEAVE_ROOM: u8 = 7;
|
||||||
|
pub const OP_REJOIN: u8 = 8;
|
||||||
|
pub const OP_SET_PLAYER_PROPS: u8 = 9;
|
||||||
|
pub const OP_SET_ROOM_PROPS: u8 = 10;
|
||||||
|
pub const OP_RPC: u8 = 11;
|
||||||
|
pub const OP_PING: u8 = 12;
|
||||||
|
|
||||||
|
// Server -> client opcodes.
|
||||||
|
pub const OP_AUTH_OK: u8 = 20;
|
||||||
|
pub const OP_JOINED_LOBBY: u8 = 21;
|
||||||
|
pub const OP_LEFT_LOBBY: u8 = 22;
|
||||||
|
pub const OP_JOINED_ROOM: u8 = 23;
|
||||||
|
pub const OP_CREATE_FAILED: u8 = 24;
|
||||||
|
pub const OP_JOIN_FAILED: u8 = 25;
|
||||||
|
pub const OP_PLAYER_ENTERED: u8 = 26;
|
||||||
|
pub const OP_PLAYER_LEFT: u8 = 27;
|
||||||
|
pub const OP_LEFT_ROOM: u8 = 28;
|
||||||
|
pub const OP_ROOM_PROPS_CHANGED: u8 = 29;
|
||||||
|
pub const OP_PLAYER_PROPS_CHANGED: u8 = 30;
|
||||||
|
pub const OP_MASTER_SWITCHED: u8 = 31;
|
||||||
|
// Distinct name from the client-side OP_RPC (11); same message, opposite direction.
|
||||||
|
pub const OP_RPC_BROADCAST: u8 = 32;
|
||||||
|
pub const OP_PONG: u8 = 33;
|
||||||
|
pub const OP_KICKED: u8 = 34;
|
||||||
|
|
||||||
|
// Close codes. The spec names 4001 and 4002; 4000 covers the framing errors it
|
||||||
|
// describes ("Text frames are a protocol error -> close", malformed frame, unknown
|
||||||
|
// opcode) without naming a code for them.
|
||||||
|
pub const CLOSE_PROTOCOL: u16 = 4000;
|
||||||
|
pub const CLOSE_UNAUTHENTICATED: u16 = 4001;
|
||||||
|
pub const CLOSE_RATE_LIMIT: u16 = 4002;
|
||||||
|
// Added with the liveness sweep: nothing was heard from this connection for three ping
|
||||||
|
// intervals, so the relay stops holding its seat. See LIVENESS_TIMEOUT in ws.rs.
|
||||||
|
pub const CLOSE_IDLE_TIMEOUT: u16 = 4003;
|
||||||
|
|
||||||
|
// Photon ErrorCode mirrors, so the client's existing logging keeps its meaning.
|
||||||
|
pub const ERR_GAME_ID_ALREADY_EXISTS: i32 = 32766;
|
||||||
|
pub const ERR_GAME_FULL: i32 = 32765;
|
||||||
|
pub const ERR_GAME_CLOSED: i32 = 32764;
|
||||||
|
pub const ERR_NO_RANDOM_MATCH_FOUND: i32 = 32760;
|
||||||
|
pub const ERR_GAME_DOES_NOT_EXIST: i32 = 32758;
|
||||||
|
|
||||||
|
// Kicked causes. A room only dies once nobody is left to be told, so cause 1 is still
|
||||||
|
// reserved — encoded, decoded and tested so the C# side can be written against it now.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const KICK_ROOM_DESTROYED: i32 = 1;
|
||||||
|
// Cause 2 is real: the liveness sweep sends it immediately before close 4003, so a client
|
||||||
|
// that is alive enough to read (a suspended app coming back, a stalled network) can say
|
||||||
|
// why it was dropped instead of showing a bare socket error.
|
||||||
|
pub const KICK_IDLE_TIMEOUT: i32 = 2;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Values and maps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Value {
|
||||||
|
Null,
|
||||||
|
Int(i32),
|
||||||
|
Str(String),
|
||||||
|
Bool(bool),
|
||||||
|
Double(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
pub fn as_int(&self) -> Option<i32> {
|
||||||
|
match self {
|
||||||
|
Value::Int(v) => Some(*v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Value::Str(v) => Some(v),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordered key -> value bag. Ordering is not semantically meaningful on the wire, but
|
||||||
|
// keeping insertion order makes the changed-subset broadcasts arrive in the order the
|
||||||
|
// sender wrote them, which is one less thing for the client to reason about (and makes
|
||||||
|
// the tests deterministic).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Map(Vec<(String, Value)>);
|
||||||
|
|
||||||
|
impl Map {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Map(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate keys collapse last-writer-wins, exactly like the Hashtable this mirrors.
|
||||||
|
// Convenience for the tests and for any caller building a bag from scratch; the
|
||||||
|
// relay itself only ever merges into an existing bag.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_pairs<I, K>(pairs: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (K, Value)>,
|
||||||
|
K: Into<String>,
|
||||||
|
{
|
||||||
|
let mut map = Map::new();
|
||||||
|
for (key, value) in pairs {
|
||||||
|
map.set(key, value);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||||
|
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_int(&self, key: &str) -> Option<i32> {
|
||||||
|
self.get(key).and_then(Value::as_int)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_str(&self, key: &str) -> Option<&str> {
|
||||||
|
self.get(key).and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-writer-wins in place: an existing key keeps its position.
|
||||||
|
pub fn set<K: Into<String>>(&mut self, key: K, value: Value) {
|
||||||
|
let key = key.into();
|
||||||
|
match self.0.iter_mut().find(|(k, _)| *k == key) {
|
||||||
|
Some(slot) => slot.1 = value,
|
||||||
|
None => self.0.push((key, value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-writer-wins merge of `other` INTO self. The relay uses it for property updates
|
||||||
|
// and for seeding/merging a joiner's bag out of the seating op's `playerProps`.
|
||||||
|
pub fn merge(&mut self, other: &Map) {
|
||||||
|
for (key, value) in other.iter() {
|
||||||
|
self.set(key, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
|
||||||
|
self.0.iter().map(|(k, v)| (k.as_str(), v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ClientMsg {
|
||||||
|
Auth { user_id: String, token: String },
|
||||||
|
JoinLobby { name: String },
|
||||||
|
LeaveLobby,
|
||||||
|
// `props` is the ROOM bag; `player_props` is the joiner's own bag, mirroring Photon's
|
||||||
|
// OpCreateRoom/OpJoinRoom actorProperties. See docs/multi-live-ws-protocol.md,
|
||||||
|
// "Player properties at join time".
|
||||||
|
CreateRoom {
|
||||||
|
name: String,
|
||||||
|
max_players: u8,
|
||||||
|
visible: bool,
|
||||||
|
open: bool,
|
||||||
|
props: Map,
|
||||||
|
lobby_prop_keys: Vec<String>,
|
||||||
|
player_props: Map,
|
||||||
|
},
|
||||||
|
JoinRoom { name: String, player_props: Map },
|
||||||
|
JoinRandom { power_min: i32, power_max: i32, levels: Vec<Value>, player_props: Map },
|
||||||
|
LeaveRoom,
|
||||||
|
Rejoin { name: String, player_props: Map },
|
||||||
|
SetPlayerProps { props: Map },
|
||||||
|
SetRoomProps { props: Map },
|
||||||
|
Rpc { name: String, params: Vec<Value> },
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ServerMsg {
|
||||||
|
AuthOk { actorless_time_ms: f64 },
|
||||||
|
JoinedLobby,
|
||||||
|
LeftLobby,
|
||||||
|
JoinedRoom {
|
||||||
|
room_name: String,
|
||||||
|
your_actor: i32,
|
||||||
|
master_actor: i32,
|
||||||
|
room_props: Map,
|
||||||
|
players: Vec<(i32, Map)>,
|
||||||
|
},
|
||||||
|
CreateFailed { code: i32, msg: String },
|
||||||
|
JoinFailed { code: i32, msg: String },
|
||||||
|
PlayerEntered { actor: i32, props: Map },
|
||||||
|
PlayerLeft { actor: i32, inactive: bool },
|
||||||
|
LeftRoom,
|
||||||
|
RoomPropsChanged { props: Map },
|
||||||
|
PlayerPropsChanged { actor: i32, props: Map },
|
||||||
|
MasterSwitched { new_master_actor: i32 },
|
||||||
|
Rpc { sender_actor: i32, name: String, params: Vec<Value> },
|
||||||
|
Pong { server_time_ms: f64 },
|
||||||
|
// Sent by the liveness sweep with KICK_IDLE_TIMEOUT; KICK_ROOM_DESTROYED is reserved.
|
||||||
|
Kicked { cause: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum DecodeError {
|
||||||
|
// Frame ran out before the field did.
|
||||||
|
Truncated,
|
||||||
|
// Zero-length frame: there is not even an opcode.
|
||||||
|
Empty,
|
||||||
|
UnknownOp(u8),
|
||||||
|
UnknownTag(u8),
|
||||||
|
BadUtf8,
|
||||||
|
// "One frame = one message" - anything after the message is a framing bug.
|
||||||
|
TrailingBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DecodeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
DecodeError::Truncated => write!(f, "truncated frame"),
|
||||||
|
DecodeError::Empty => write!(f, "empty frame"),
|
||||||
|
DecodeError::UnknownOp(op) => write!(f, "unknown opcode {}", op),
|
||||||
|
DecodeError::UnknownTag(tag) => write!(f, "unknown value tag {}", tag),
|
||||||
|
DecodeError::BadUtf8 => write!(f, "string is not valid utf-8"),
|
||||||
|
DecodeError::TrailingBytes => write!(f, "trailing bytes after message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
buf: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(buf: &'a [u8]) -> Self {
|
||||||
|
Reader { buf, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
|
||||||
|
let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
|
||||||
|
if end > self.buf.len() {
|
||||||
|
return Err(DecodeError::Truncated);
|
||||||
|
}
|
||||||
|
let out = &self.buf[self.pos..end];
|
||||||
|
self.pos = end;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u8(&mut self) -> Result<u8, DecodeError> {
|
||||||
|
Ok(self.take(1)?[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool(&mut self) -> Result<bool, DecodeError> {
|
||||||
|
// Photon booleans are one byte; anything non-zero is true.
|
||||||
|
Ok(self.u8()? != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16(&mut self) -> Result<u16, DecodeError> {
|
||||||
|
let b = self.take(2)?;
|
||||||
|
Ok(u16::from_le_bytes([b[0], b[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn i32(&mut self) -> Result<i32, DecodeError> {
|
||||||
|
let b = self.take(4)?;
|
||||||
|
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f64(&mut self) -> Result<f64, DecodeError> {
|
||||||
|
let b = self.take(8)?;
|
||||||
|
Ok(f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self) -> Result<String, DecodeError> {
|
||||||
|
let len = self.u16()? as usize;
|
||||||
|
let bytes = self.take(len)?;
|
||||||
|
String::from_utf8(bytes.to_vec()).map_err(|_| DecodeError::BadUtf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&mut self) -> Result<Value, DecodeError> {
|
||||||
|
let tag = self.u8()?;
|
||||||
|
match tag {
|
||||||
|
TAG_NULL => Ok(Value::Null),
|
||||||
|
TAG_INT => Ok(Value::Int(self.i32()?)),
|
||||||
|
TAG_STRING => Ok(Value::Str(self.string()?)),
|
||||||
|
TAG_BOOL => Ok(Value::Bool(self.bool()?)),
|
||||||
|
TAG_DOUBLE => Ok(Value::Double(self.f64()?)),
|
||||||
|
other => Err(DecodeError::UnknownTag(other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(&mut self) -> Result<Map, DecodeError> {
|
||||||
|
let count = self.u8()?;
|
||||||
|
let mut map = Map::new();
|
||||||
|
for _ in 0..count {
|
||||||
|
let key = self.string()?;
|
||||||
|
let value = self.value()?;
|
||||||
|
map.set(key, value);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_list(&mut self) -> Result<Vec<Value>, DecodeError> {
|
||||||
|
let count = self.u8()?;
|
||||||
|
let mut out = Vec::with_capacity(count as usize);
|
||||||
|
for _ in 0..count {
|
||||||
|
out.push(self.value()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_list(&mut self) -> Result<Vec<String>, DecodeError> {
|
||||||
|
let count = self.u8()?;
|
||||||
|
let mut out = Vec::with_capacity(count as usize);
|
||||||
|
for _ in 0..count {
|
||||||
|
out.push(self.string()?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish<T>(&self, value: T) -> Result<T, DecodeError> {
|
||||||
|
if self.pos != self.buf.len() {
|
||||||
|
return Err(DecodeError::TrailingBytes);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_client(buf: &[u8]) -> Result<ClientMsg, DecodeError> {
|
||||||
|
let mut r = Reader::new(buf);
|
||||||
|
let op = r.u8().map_err(|_| DecodeError::Empty)?;
|
||||||
|
let msg = match op {
|
||||||
|
OP_AUTH => ClientMsg::Auth { user_id: r.string()?, token: r.string()? },
|
||||||
|
OP_JOIN_LOBBY => ClientMsg::JoinLobby { name: r.string()? },
|
||||||
|
OP_LEAVE_LOBBY => ClientMsg::LeaveLobby,
|
||||||
|
OP_CREATE_ROOM => ClientMsg::CreateRoom {
|
||||||
|
name: r.string()?,
|
||||||
|
max_players: r.u8()?,
|
||||||
|
visible: r.bool()?,
|
||||||
|
open: r.bool()?,
|
||||||
|
props: r.map()?,
|
||||||
|
lobby_prop_keys: r.string_list()?,
|
||||||
|
player_props: r.map()?,
|
||||||
|
},
|
||||||
|
OP_JOIN_ROOM => ClientMsg::JoinRoom { name: r.string()?, player_props: r.map()? },
|
||||||
|
OP_JOIN_RANDOM => ClientMsg::JoinRandom {
|
||||||
|
power_min: r.i32()?,
|
||||||
|
power_max: r.i32()?,
|
||||||
|
levels: r.value_list()?,
|
||||||
|
player_props: r.map()?,
|
||||||
|
},
|
||||||
|
OP_LEAVE_ROOM => ClientMsg::LeaveRoom,
|
||||||
|
OP_REJOIN => ClientMsg::Rejoin { name: r.string()?, player_props: r.map()? },
|
||||||
|
OP_SET_PLAYER_PROPS => ClientMsg::SetPlayerProps { props: r.map()? },
|
||||||
|
OP_SET_ROOM_PROPS => ClientMsg::SetRoomProps { props: r.map()? },
|
||||||
|
OP_RPC => ClientMsg::Rpc { name: r.string()?, params: r.value_list()? },
|
||||||
|
OP_PING => ClientMsg::Ping,
|
||||||
|
other => return Err(DecodeError::UnknownOp(other)),
|
||||||
|
};
|
||||||
|
r.finish(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client's half of the codec. The relay never calls it; it is the executable
|
||||||
|
// reference the C# rewrite is written against, and what the round-trip tests drive.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn decode_server(buf: &[u8]) -> Result<ServerMsg, DecodeError> {
|
||||||
|
let mut r = Reader::new(buf);
|
||||||
|
let op = r.u8().map_err(|_| DecodeError::Empty)?;
|
||||||
|
let msg = match op {
|
||||||
|
OP_AUTH_OK => ServerMsg::AuthOk { actorless_time_ms: r.f64()? },
|
||||||
|
OP_JOINED_LOBBY => ServerMsg::JoinedLobby,
|
||||||
|
OP_LEFT_LOBBY => ServerMsg::LeftLobby,
|
||||||
|
OP_JOINED_ROOM => {
|
||||||
|
let room_name = r.string()?;
|
||||||
|
let your_actor = r.i32()?;
|
||||||
|
let master_actor = r.i32()?;
|
||||||
|
let room_props = r.map()?;
|
||||||
|
let count = r.u8()?;
|
||||||
|
let mut players = Vec::with_capacity(count as usize);
|
||||||
|
for _ in 0..count {
|
||||||
|
let actor = r.i32()?;
|
||||||
|
players.push((actor, r.map()?));
|
||||||
|
}
|
||||||
|
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players }
|
||||||
|
}
|
||||||
|
OP_CREATE_FAILED => ServerMsg::CreateFailed { code: r.i32()?, msg: r.string()? },
|
||||||
|
OP_JOIN_FAILED => ServerMsg::JoinFailed { code: r.i32()?, msg: r.string()? },
|
||||||
|
OP_PLAYER_ENTERED => ServerMsg::PlayerEntered { actor: r.i32()?, props: r.map()? },
|
||||||
|
OP_PLAYER_LEFT => ServerMsg::PlayerLeft { actor: r.i32()?, inactive: r.bool()? },
|
||||||
|
OP_LEFT_ROOM => ServerMsg::LeftRoom,
|
||||||
|
OP_ROOM_PROPS_CHANGED => ServerMsg::RoomPropsChanged { props: r.map()? },
|
||||||
|
OP_PLAYER_PROPS_CHANGED => {
|
||||||
|
ServerMsg::PlayerPropsChanged { actor: r.i32()?, props: r.map()? }
|
||||||
|
}
|
||||||
|
OP_MASTER_SWITCHED => ServerMsg::MasterSwitched { new_master_actor: r.i32()? },
|
||||||
|
OP_RPC_BROADCAST => ServerMsg::Rpc {
|
||||||
|
sender_actor: r.i32()?,
|
||||||
|
name: r.string()?,
|
||||||
|
params: r.value_list()?,
|
||||||
|
},
|
||||||
|
OP_PONG => ServerMsg::Pong { server_time_ms: r.f64()? },
|
||||||
|
OP_KICKED => ServerMsg::Kicked { cause: r.i32()? },
|
||||||
|
other => return Err(DecodeError::UnknownOp(other)),
|
||||||
|
};
|
||||||
|
r.finish(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The relay never legitimately produces a string past 64KiB or a count past 255 (keys
|
||||||
|
// are one or two characters, rooms hold four players, RPC params are a handful of ints),
|
||||||
|
// so encoding is infallible and the guards below only exist to keep a bug from emitting
|
||||||
|
// a frame the peer would mis-frame. They log and clamp rather than panic in a handler.
|
||||||
|
fn put_str(out: &mut Vec<u8>, s: &str) {
|
||||||
|
let mut bytes = s.as_bytes();
|
||||||
|
if bytes.len() > u16::MAX as usize {
|
||||||
|
println!("multi_live/ws: string of {} bytes truncated to fit u16 length", bytes.len());
|
||||||
|
let mut end = u16::MAX as usize;
|
||||||
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
bytes = &s.as_bytes()[..end];
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_count(out: &mut Vec<u8>, count: usize, what: &str) -> usize {
|
||||||
|
let clamped = if count > u8::MAX as usize {
|
||||||
|
println!("multi_live/ws: {} count {} clamped to 255", what, count);
|
||||||
|
u8::MAX as usize
|
||||||
|
} else {
|
||||||
|
count
|
||||||
|
};
|
||||||
|
out.push(clamped as u8);
|
||||||
|
clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_value(out: &mut Vec<u8>, value: &Value) {
|
||||||
|
match value {
|
||||||
|
Value::Null => out.push(TAG_NULL),
|
||||||
|
Value::Int(v) => {
|
||||||
|
out.push(TAG_INT);
|
||||||
|
out.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
Value::Str(v) => {
|
||||||
|
out.push(TAG_STRING);
|
||||||
|
put_str(out, v);
|
||||||
|
}
|
||||||
|
Value::Bool(v) => {
|
||||||
|
out.push(TAG_BOOL);
|
||||||
|
out.push(if *v { 1 } else { 0 });
|
||||||
|
}
|
||||||
|
Value::Double(v) => {
|
||||||
|
out.push(TAG_DOUBLE);
|
||||||
|
out.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_map(out: &mut Vec<u8>, map: &Map) {
|
||||||
|
let count = put_count(out, map.len(), "map");
|
||||||
|
for (key, value) in map.iter().take(count) {
|
||||||
|
put_str(out, key);
|
||||||
|
put_value(out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_value_list(out: &mut Vec<u8>, values: &[Value]) {
|
||||||
|
let count = put_count(out, values.len(), "value list");
|
||||||
|
for value in values.iter().take(count) {
|
||||||
|
put_value(out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn put_string_list(out: &mut Vec<u8>, values: &[String]) {
|
||||||
|
let count = put_count(out, values.len(), "string list");
|
||||||
|
for value in values.iter().take(count) {
|
||||||
|
put_str(out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See decode_server: the client's half, kept honest by the round-trip tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn encode_client(msg: &ClientMsg) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match msg {
|
||||||
|
ClientMsg::Auth { user_id, token } => {
|
||||||
|
out.push(OP_AUTH);
|
||||||
|
put_str(&mut out, user_id);
|
||||||
|
put_str(&mut out, token);
|
||||||
|
}
|
||||||
|
ClientMsg::JoinLobby { name } => {
|
||||||
|
out.push(OP_JOIN_LOBBY);
|
||||||
|
put_str(&mut out, name);
|
||||||
|
}
|
||||||
|
ClientMsg::LeaveLobby => out.push(OP_LEAVE_LOBBY),
|
||||||
|
ClientMsg::CreateRoom { name, max_players, visible, open, props, lobby_prop_keys, player_props } => {
|
||||||
|
out.push(OP_CREATE_ROOM);
|
||||||
|
put_str(&mut out, name);
|
||||||
|
out.push(*max_players);
|
||||||
|
out.push(if *visible { 1 } else { 0 });
|
||||||
|
out.push(if *open { 1 } else { 0 });
|
||||||
|
put_map(&mut out, props);
|
||||||
|
put_string_list(&mut out, lobby_prop_keys);
|
||||||
|
put_map(&mut out, player_props);
|
||||||
|
}
|
||||||
|
ClientMsg::JoinRoom { name, player_props } => {
|
||||||
|
out.push(OP_JOIN_ROOM);
|
||||||
|
put_str(&mut out, name);
|
||||||
|
put_map(&mut out, player_props);
|
||||||
|
}
|
||||||
|
ClientMsg::JoinRandom { power_min, power_max, levels, player_props } => {
|
||||||
|
out.push(OP_JOIN_RANDOM);
|
||||||
|
out.extend_from_slice(&power_min.to_le_bytes());
|
||||||
|
out.extend_from_slice(&power_max.to_le_bytes());
|
||||||
|
put_value_list(&mut out, levels);
|
||||||
|
put_map(&mut out, player_props);
|
||||||
|
}
|
||||||
|
ClientMsg::LeaveRoom => out.push(OP_LEAVE_ROOM),
|
||||||
|
ClientMsg::Rejoin { name, player_props } => {
|
||||||
|
out.push(OP_REJOIN);
|
||||||
|
put_str(&mut out, name);
|
||||||
|
put_map(&mut out, player_props);
|
||||||
|
}
|
||||||
|
ClientMsg::SetPlayerProps { props } => {
|
||||||
|
out.push(OP_SET_PLAYER_PROPS);
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
ClientMsg::SetRoomProps { props } => {
|
||||||
|
out.push(OP_SET_ROOM_PROPS);
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
ClientMsg::Rpc { name, params } => {
|
||||||
|
out.push(OP_RPC);
|
||||||
|
put_str(&mut out, name);
|
||||||
|
put_value_list(&mut out, params);
|
||||||
|
}
|
||||||
|
ClientMsg::Ping => out.push(OP_PING),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_server(msg: &ServerMsg) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match msg {
|
||||||
|
ServerMsg::AuthOk { actorless_time_ms } => {
|
||||||
|
out.push(OP_AUTH_OK);
|
||||||
|
out.extend_from_slice(&actorless_time_ms.to_le_bytes());
|
||||||
|
}
|
||||||
|
ServerMsg::JoinedLobby => out.push(OP_JOINED_LOBBY),
|
||||||
|
ServerMsg::LeftLobby => out.push(OP_LEFT_LOBBY),
|
||||||
|
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players } => {
|
||||||
|
out.push(OP_JOINED_ROOM);
|
||||||
|
put_str(&mut out, room_name);
|
||||||
|
out.extend_from_slice(&your_actor.to_le_bytes());
|
||||||
|
out.extend_from_slice(&master_actor.to_le_bytes());
|
||||||
|
put_map(&mut out, room_props);
|
||||||
|
let count = put_count(&mut out, players.len(), "player");
|
||||||
|
for (actor, props) in players.iter().take(count) {
|
||||||
|
out.extend_from_slice(&actor.to_le_bytes());
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerMsg::CreateFailed { code, msg } => {
|
||||||
|
out.push(OP_CREATE_FAILED);
|
||||||
|
out.extend_from_slice(&code.to_le_bytes());
|
||||||
|
put_str(&mut out, msg);
|
||||||
|
}
|
||||||
|
ServerMsg::JoinFailed { code, msg } => {
|
||||||
|
out.push(OP_JOIN_FAILED);
|
||||||
|
out.extend_from_slice(&code.to_le_bytes());
|
||||||
|
put_str(&mut out, msg);
|
||||||
|
}
|
||||||
|
ServerMsg::PlayerEntered { actor, props } => {
|
||||||
|
out.push(OP_PLAYER_ENTERED);
|
||||||
|
out.extend_from_slice(&actor.to_le_bytes());
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
ServerMsg::PlayerLeft { actor, inactive } => {
|
||||||
|
out.push(OP_PLAYER_LEFT);
|
||||||
|
out.extend_from_slice(&actor.to_le_bytes());
|
||||||
|
out.push(if *inactive { 1 } else { 0 });
|
||||||
|
}
|
||||||
|
ServerMsg::LeftRoom => out.push(OP_LEFT_ROOM),
|
||||||
|
ServerMsg::RoomPropsChanged { props } => {
|
||||||
|
out.push(OP_ROOM_PROPS_CHANGED);
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
ServerMsg::PlayerPropsChanged { actor, props } => {
|
||||||
|
out.push(OP_PLAYER_PROPS_CHANGED);
|
||||||
|
out.extend_from_slice(&actor.to_le_bytes());
|
||||||
|
put_map(&mut out, props);
|
||||||
|
}
|
||||||
|
ServerMsg::MasterSwitched { new_master_actor } => {
|
||||||
|
out.push(OP_MASTER_SWITCHED);
|
||||||
|
out.extend_from_slice(&new_master_actor.to_le_bytes());
|
||||||
|
}
|
||||||
|
ServerMsg::Rpc { sender_actor, name, params } => {
|
||||||
|
out.push(OP_RPC_BROADCAST);
|
||||||
|
out.extend_from_slice(&sender_actor.to_le_bytes());
|
||||||
|
put_str(&mut out, name);
|
||||||
|
put_value_list(&mut out, params);
|
||||||
|
}
|
||||||
|
ServerMsg::Pong { server_time_ms } => {
|
||||||
|
out.push(OP_PONG);
|
||||||
|
out.extend_from_slice(&server_time_ms.to_le_bytes());
|
||||||
|
}
|
||||||
|
ServerMsg::Kicked { cause } => {
|
||||||
|
out.push(OP_KICKED);
|
||||||
|
out.extend_from_slice(&cause.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn round_client(msg: ClientMsg) {
|
||||||
|
let bytes = encode_client(&msg);
|
||||||
|
let back = decode_client(&bytes).expect("client message decodes");
|
||||||
|
assert_eq!(back, msg);
|
||||||
|
assert_eq!(encode_client(&back), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_server(msg: ServerMsg) {
|
||||||
|
let bytes = encode_server(&msg);
|
||||||
|
let back = decode_server(&bytes).expect("server message decodes");
|
||||||
|
assert_eq!(back, msg);
|
||||||
|
assert_eq!(encode_server(&back), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_map() -> Map {
|
||||||
|
Map::from_pairs(vec![
|
||||||
|
("A", Value::Int(-7)),
|
||||||
|
("B", Value::Str("ラブライブ".to_string())),
|
||||||
|
("C", Value::Bool(true)),
|
||||||
|
("D", Value::Double(0.5)),
|
||||||
|
("E", Value::Null),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_client_message_round_trips() {
|
||||||
|
round_client(ClientMsg::Auth { user_id: "12345".into(), token: "a-uuid".into() });
|
||||||
|
round_client(ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() });
|
||||||
|
round_client(ClientMsg::LeaveLobby);
|
||||||
|
round_client(ClientMsg::CreateRoom {
|
||||||
|
name: "123456".into(),
|
||||||
|
max_players: 4,
|
||||||
|
visible: false,
|
||||||
|
open: true,
|
||||||
|
props: sample_map(),
|
||||||
|
lobby_prop_keys: vec!["C0".into(), "C1".into()],
|
||||||
|
player_props: sample_map(),
|
||||||
|
});
|
||||||
|
round_client(ClientMsg::JoinRoom {
|
||||||
|
name: "042719".into(),
|
||||||
|
player_props: sample_map(),
|
||||||
|
});
|
||||||
|
round_client(ClientMsg::JoinRandom {
|
||||||
|
power_min: 0,
|
||||||
|
power_max: i32::MAX,
|
||||||
|
levels: vec![Value::Int(1), Value::Int(4)],
|
||||||
|
player_props: sample_map(),
|
||||||
|
});
|
||||||
|
round_client(ClientMsg::LeaveRoom);
|
||||||
|
round_client(ClientMsg::Rejoin {
|
||||||
|
name: "1000000".into(),
|
||||||
|
player_props: sample_map(),
|
||||||
|
});
|
||||||
|
round_client(ClientMsg::SetPlayerProps { props: sample_map() });
|
||||||
|
round_client(ClientMsg::SetRoomProps { props: sample_map() });
|
||||||
|
round_client(ClientMsg::Rpc {
|
||||||
|
name: "SendStamp".into(),
|
||||||
|
params: vec![Value::Int(3), Value::Str("hi".into())],
|
||||||
|
});
|
||||||
|
round_client(ClientMsg::Ping);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_server_message_round_trips() {
|
||||||
|
round_server(ServerMsg::AuthOk { actorless_time_ms: 1_754_500_000_123.0 });
|
||||||
|
round_server(ServerMsg::JoinedLobby);
|
||||||
|
round_server(ServerMsg::LeftLobby);
|
||||||
|
round_server(ServerMsg::JoinedRoom {
|
||||||
|
room_name: "1000000".into(),
|
||||||
|
your_actor: 2,
|
||||||
|
master_actor: 1,
|
||||||
|
room_props: sample_map(),
|
||||||
|
players: vec![(1, sample_map()), (2, Map::new())],
|
||||||
|
});
|
||||||
|
round_server(ServerMsg::CreateFailed {
|
||||||
|
code: ERR_GAME_ID_ALREADY_EXISTS,
|
||||||
|
msg: "GameIdAlreadyExists".into(),
|
||||||
|
});
|
||||||
|
round_server(ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() });
|
||||||
|
round_server(ServerMsg::PlayerEntered { actor: 3, props: sample_map() });
|
||||||
|
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: true });
|
||||||
|
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: false });
|
||||||
|
round_server(ServerMsg::LeftRoom);
|
||||||
|
round_server(ServerMsg::RoomPropsChanged { props: sample_map() });
|
||||||
|
round_server(ServerMsg::PlayerPropsChanged { actor: 4, props: sample_map() });
|
||||||
|
round_server(ServerMsg::MasterSwitched { new_master_actor: 2 });
|
||||||
|
round_server(ServerMsg::Rpc {
|
||||||
|
sender_actor: 1,
|
||||||
|
name: "_MoveScene".into(),
|
||||||
|
params: vec![Value::Null],
|
||||||
|
});
|
||||||
|
round_server(ServerMsg::Pong { server_time_ms: -0.0 });
|
||||||
|
round_server(ServerMsg::Kicked { cause: KICK_ROOM_DESTROYED });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_map_and_empty_strings_round_trip() {
|
||||||
|
round_client(ClientMsg::Auth { user_id: String::new(), token: String::new() });
|
||||||
|
round_client(ClientMsg::JoinLobby { name: String::new() });
|
||||||
|
round_client(ClientMsg::SetPlayerProps { props: Map::new() });
|
||||||
|
round_client(ClientMsg::Rpc { name: String::new(), params: Vec::new() });
|
||||||
|
round_client(ClientMsg::CreateRoom {
|
||||||
|
name: String::new(),
|
||||||
|
max_players: 0,
|
||||||
|
visible: true,
|
||||||
|
open: false,
|
||||||
|
props: Map::new(),
|
||||||
|
lobby_prop_keys: Vec::new(),
|
||||||
|
player_props: Map::new(),
|
||||||
|
});
|
||||||
|
// A joiner that has committed nothing yet sends an empty bag on every seating op.
|
||||||
|
round_client(ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() });
|
||||||
|
round_client(ClientMsg::Rejoin { name: String::new(), player_props: Map::new() });
|
||||||
|
round_client(ClientMsg::JoinRandom {
|
||||||
|
power_min: 0,
|
||||||
|
power_max: 0,
|
||||||
|
levels: Vec::new(),
|
||||||
|
player_props: Map::new(),
|
||||||
|
});
|
||||||
|
// The trailing playerProps of a JoinRoom is exactly one byte when empty.
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() }),
|
||||||
|
vec![OP_JOIN_ROOM, 0, 0, 0]
|
||||||
|
);
|
||||||
|
round_server(ServerMsg::JoinedRoom {
|
||||||
|
room_name: String::new(),
|
||||||
|
your_actor: 1,
|
||||||
|
master_actor: 1,
|
||||||
|
room_props: Map::new(),
|
||||||
|
players: Vec::new(),
|
||||||
|
});
|
||||||
|
// An empty map is exactly one byte of payload.
|
||||||
|
assert_eq!(encode_client(&ClientMsg::SetRoomProps { props: Map::new() }), vec![OP_SET_ROOM_PROPS, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_u8_counts_round_trip() {
|
||||||
|
let map = Map::from_pairs((0..255).map(|i| (format!("k{}", i), Value::Int(i))));
|
||||||
|
assert_eq!(map.len(), 255);
|
||||||
|
round_client(ClientMsg::SetPlayerProps { props: map.clone() });
|
||||||
|
|
||||||
|
let params: Vec<Value> = (0..255).map(Value::Int).collect();
|
||||||
|
round_client(ClientMsg::Rpc { name: "SendStamp".into(), params });
|
||||||
|
|
||||||
|
let keys: Vec<String> = (0..255).map(|i| format!("k{}", i)).collect();
|
||||||
|
round_client(ClientMsg::CreateRoom {
|
||||||
|
name: "n".into(),
|
||||||
|
max_players: 255,
|
||||||
|
visible: true,
|
||||||
|
open: true,
|
||||||
|
props: map.clone(),
|
||||||
|
lobby_prop_keys: keys,
|
||||||
|
player_props: map.clone(),
|
||||||
|
});
|
||||||
|
// A full 255-key bag on each of the other three seating ops.
|
||||||
|
round_client(ClientMsg::JoinRoom { name: "n".into(), player_props: map.clone() });
|
||||||
|
round_client(ClientMsg::Rejoin { name: "n".into(), player_props: map.clone() });
|
||||||
|
round_client(ClientMsg::JoinRandom {
|
||||||
|
power_min: 0,
|
||||||
|
power_max: 1,
|
||||||
|
levels: vec![Value::Int(4)],
|
||||||
|
player_props: map,
|
||||||
|
});
|
||||||
|
|
||||||
|
let players: Vec<(i32, Map)> = (0..255).map(|i| (i, Map::new())).collect();
|
||||||
|
round_server(ServerMsg::JoinedRoom {
|
||||||
|
room_name: "n".into(),
|
||||||
|
your_actor: 1,
|
||||||
|
master_actor: 1,
|
||||||
|
room_props: Map::new(),
|
||||||
|
players,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integer_and_double_extremes_survive() {
|
||||||
|
round_client(ClientMsg::JoinRandom {
|
||||||
|
power_min: i32::MIN,
|
||||||
|
power_max: i32::MAX,
|
||||||
|
levels: vec![Value::Int(i32::MIN), Value::Int(i32::MAX), Value::Int(0)],
|
||||||
|
player_props: Map::new(),
|
||||||
|
});
|
||||||
|
round_server(ServerMsg::PlayerEntered {
|
||||||
|
actor: i32::MIN,
|
||||||
|
props: Map::from_pairs(vec![
|
||||||
|
("a", Value::Double(f64::MAX)),
|
||||||
|
("b", Value::Double(f64::MIN_POSITIVE)),
|
||||||
|
("c", Value::Double(f64::INFINITY)),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_layout_is_byte_exact() {
|
||||||
|
// Guard the framing itself: little-endian everywhere, u16 string lengths,
|
||||||
|
// u8 counts, tag-then-payload values. If this test needs updating, the C#
|
||||||
|
// client needs updating too.
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::Auth { user_id: "7".into(), token: "ab".into() }),
|
||||||
|
vec![OP_AUTH, 1, 0, b'7', 2, 0, b'a', b'b']
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::JoinRandom {
|
||||||
|
power_min: 1,
|
||||||
|
power_max: 256,
|
||||||
|
levels: vec![Value::Int(4)],
|
||||||
|
player_props: Map::new(),
|
||||||
|
}),
|
||||||
|
// ... levels ValueList ..., then the trailing empty playerProps map (count 0).
|
||||||
|
vec![OP_JOIN_RANDOM, 1, 0, 0, 0, 0, 1, 0, 0, 1, TAG_INT, 4, 0, 0, 0, 0]
|
||||||
|
);
|
||||||
|
// The seating ops' trailing bag is an ordinary Map: count:u8 then (key,value)*.
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::JoinRoom {
|
||||||
|
name: "42".into(),
|
||||||
|
player_props: Map::from_pairs(vec![("B", Value::Str("7".into()))]),
|
||||||
|
}),
|
||||||
|
vec![OP_JOIN_ROOM, 2, 0, b'4', b'2', 1, 1, 0, b'B', TAG_STRING, 1, 0, b'7']
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::Rejoin {
|
||||||
|
name: "42".into(),
|
||||||
|
player_props: Map::from_pairs(vec![("F", Value::Int(5))]),
|
||||||
|
}),
|
||||||
|
vec![OP_REJOIN, 2, 0, b'4', b'2', 1, 1, 0, b'F', TAG_INT, 5, 0, 0, 0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_client(&ClientMsg::SetPlayerProps {
|
||||||
|
props: Map::from_pairs(vec![("LB", Value::Int(12))]),
|
||||||
|
}),
|
||||||
|
vec![OP_SET_PLAYER_PROPS, 1, 2, 0, b'L', b'B', TAG_INT, 12, 0, 0, 0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_server(&ServerMsg::PlayerLeft { actor: 2, inactive: true }),
|
||||||
|
vec![OP_PLAYER_LEFT, 2, 0, 0, 0, 1]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_server(&ServerMsg::AuthOk { actorless_time_ms: 1.0 }),
|
||||||
|
vec![OP_AUTH_OK, 0, 0, 0, 0, 0, 0, 0xf0, 0x3f]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_frames_are_rejected() {
|
||||||
|
assert_eq!(decode_client(&[]), Err(DecodeError::Empty));
|
||||||
|
assert_eq!(decode_client(&[99]), Err(DecodeError::UnknownOp(99)));
|
||||||
|
assert_eq!(decode_server(&[99]), Err(DecodeError::UnknownOp(99)));
|
||||||
|
// JoinLobby with a length prefix longer than the payload.
|
||||||
|
assert_eq!(decode_client(&[OP_JOIN_LOBBY, 4, 0, b'a']), Err(DecodeError::Truncated));
|
||||||
|
// Ping carries nothing.
|
||||||
|
assert_eq!(decode_client(&[OP_PING, 0]), Err(DecodeError::TrailingBytes));
|
||||||
|
// Unknown value tag inside a map.
|
||||||
|
assert_eq!(
|
||||||
|
decode_client(&[OP_SET_ROOM_PROPS, 1, 1, 0, b'A', 9]),
|
||||||
|
Err(DecodeError::UnknownTag(9))
|
||||||
|
);
|
||||||
|
// A map that promises two entries but carries one.
|
||||||
|
assert_eq!(
|
||||||
|
decode_client(&[OP_SET_ROOM_PROPS, 2, 1, 0, b'A', TAG_NULL]),
|
||||||
|
Err(DecodeError::Truncated)
|
||||||
|
);
|
||||||
|
// Invalid UTF-8 in a string.
|
||||||
|
assert_eq!(decode_client(&[OP_JOIN_ROOM, 1, 0, 0xff]), Err(DecodeError::BadUtf8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_map_keys_collapse_last_writer_wins() {
|
||||||
|
let bytes = vec![
|
||||||
|
OP_SET_ROOM_PROPS, 2,
|
||||||
|
1, 0, b'A', TAG_INT, 1, 0, 0, 0,
|
||||||
|
1, 0, b'A', TAG_INT, 2, 0, 0, 0,
|
||||||
|
];
|
||||||
|
let ClientMsg::SetRoomProps { props } = decode_client(&bytes).unwrap() else {
|
||||||
|
panic!("expected SetRoomProps");
|
||||||
|
};
|
||||||
|
assert_eq!(props.len(), 1);
|
||||||
|
assert_eq!(props.get_int("A"), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_set_is_last_writer_wins_in_place() {
|
||||||
|
let mut map = Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Int(2))]);
|
||||||
|
map.set("A", Value::Int(9));
|
||||||
|
assert_eq!(map.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
map.iter().map(|(k, _)| k).collect::<Vec<_>>(),
|
||||||
|
vec!["A", "B"]
|
||||||
|
);
|
||||||
|
assert_eq!(map.get_int("A"), Some(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bool_payload_accepts_any_nonzero() {
|
||||||
|
// Photon writes 0/1; be lenient reading, strict writing.
|
||||||
|
assert_eq!(
|
||||||
|
decode_server(&[OP_PLAYER_LEFT, 1, 0, 0, 0, 7]),
|
||||||
|
Ok(ServerMsg::PlayerLeft { actor: 1, inactive: true })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_server(&ServerMsg::PlayerLeft { actor: 1, inactive: true })[5],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2257
src/router/multi_live/rooms.rs
Normal file
367
src/router/multi_live/ws.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
// WebSocket end of the multi-live relay: GET /api/multi_live/ws.
|
||||||
|
//
|
||||||
|
// One task per connection reads frames, decodes them and drives the (synchronous,
|
||||||
|
// lock-serialised) registry in rooms.rs. A second task per connection drains that
|
||||||
|
// connection's unbounded queue into the socket. Nothing that touches the registry ever
|
||||||
|
// awaits while the lock is held, which is what lets the registry hand out a total order
|
||||||
|
// over every broadcast - see the header comment in rooms.rs.
|
||||||
|
//
|
||||||
|
// Everything the client can get wrong ends in a close: 4000 for a framing/protocol
|
||||||
|
// error, 4001 for an unauthenticated or unauthenticated-first frame, 4002 for blowing
|
||||||
|
// the inbound rate cap, 4003 for going silent long enough to be presumed dead (the
|
||||||
|
// liveness sweep - see LIVENESS_TIMEOUT in rooms.rs, and start_sweeper below).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use actix_web::rt;
|
||||||
|
use actix_web::{web, HttpRequest, HttpResponse};
|
||||||
|
use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session};
|
||||||
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
use crate::router::userdata;
|
||||||
|
use super::proto::{
|
||||||
|
self, ClientMsg, DecodeError, ServerMsg, CLOSE_PROTOCOL, CLOSE_RATE_LIMIT,
|
||||||
|
CLOSE_UNAUTHENTICATED,
|
||||||
|
};
|
||||||
|
use super::rooms::{self, ConnId, Outbound};
|
||||||
|
|
||||||
|
// "Server closes idle unauthenticated connections after 10s."
|
||||||
|
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
// "a per-connection cap of 30 inbound messages/sec ... over the cap -> close 4002".
|
||||||
|
// The honest client peaks at 3-5/sec.
|
||||||
|
const RATE_LIMIT_PER_SEC: usize = 30;
|
||||||
|
const RATE_WINDOW: Duration = Duration::from_secs(1);
|
||||||
|
// Property bags and RPC params are tens of bytes; nothing legitimate comes close.
|
||||||
|
const MAX_FRAME_BYTES: usize = 64 * 1024;
|
||||||
|
// How often the inactive-actor, empty-room and connection-liveness expiries are checked.
|
||||||
|
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
pub async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
// Older client builds carry incompatible multi implementations (pre-relay wire
|
||||||
|
// framing), so the upgrade itself is gated on the extended-protocol version — a
|
||||||
|
// non-101 answer makes the client's ConnectAsync fail cleanly into its official
|
||||||
|
// disconnect flow instead of mis-framing against the relay.
|
||||||
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
|
if protocol < super::PROTOCOL_VERSION {
|
||||||
|
println!(
|
||||||
|
"multi_live/ws: upgrade refused, X-Protocol-Version {} < {}",
|
||||||
|
protocol,
|
||||||
|
super::PROTOCOL_VERSION
|
||||||
|
);
|
||||||
|
return Ok(HttpResponse::UpgradeRequired().finish());
|
||||||
|
}
|
||||||
|
let (response, session, stream) = match actix_ws::handle(&req, body) {
|
||||||
|
Ok(parts) => {
|
||||||
|
println!("multi_live/ws: upgrade accepted, awaiting Auth");
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("multi_live/ws: upgrade REJECTED (not a websocket handshake?): {}", err);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stream = stream
|
||||||
|
.max_frame_size(MAX_FRAME_BYTES)
|
||||||
|
.aggregate_continuations()
|
||||||
|
.max_continuation_size(MAX_FRAME_BYTES);
|
||||||
|
|
||||||
|
let (tx, rx) = unbounded_channel::<Outbound>();
|
||||||
|
rt::spawn(writer(session, rx));
|
||||||
|
rt::spawn(reader(stream, tx));
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expiry timers: held-slot TTL, empty-room TTL and the connection liveness window.
|
||||||
|
//
|
||||||
|
// One task for the whole process, started from run_server so it lives on the system
|
||||||
|
// arbiter. It used to be lazily started by the first WebSocket upgrade instead, which put
|
||||||
|
// it on whichever HTTP worker happened to serve that upgrade: a panic anywhere in that
|
||||||
|
// worker took the sweeper down with it, permanently and silently, and `Once` guaranteed
|
||||||
|
// nothing would ever start it again. Every room in the process would then hold its seats
|
||||||
|
// forever.
|
||||||
|
//
|
||||||
|
// Called once per run_server, unconditionally and not behind a "started already" flag:
|
||||||
|
// each run_server builds its own actix System, and a task spawned on the previous one died
|
||||||
|
// with it (the mobile path stops and restarts the server in-process). A flag would leave
|
||||||
|
// the restarted server with no sweeper at all; two sweepers, if they ever overlapped,
|
||||||
|
// would only mean the idempotent sweep runs twice a second.
|
||||||
|
pub fn start_sweeper() {
|
||||||
|
rt::spawn(async {
|
||||||
|
let mut ticker = rt::time::interval(SWEEP_INTERVAL);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
// The guard is a temporary: it is released before the next await.
|
||||||
|
rooms::registry().sweep(Instant::now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drains this connection's FIFO queue to the socket. The queue is unbounded so that the
|
||||||
|
// registry can push a whole broadcast while holding its lock without ever blocking.
|
||||||
|
async fn writer(mut session: Session, mut rx: UnboundedReceiver<Outbound>) {
|
||||||
|
while let Some(out) = rx.recv().await {
|
||||||
|
match out {
|
||||||
|
Outbound::Msg(msg) => {
|
||||||
|
if session.binary(proto::encode_server(&msg)).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Outbound::Pong(bytes) => {
|
||||||
|
if session.pong(&bytes).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Outbound::Close(code) => {
|
||||||
|
let _ = session
|
||||||
|
.close(Some(CloseReason { code: CloseCode::Other(code), description: None }))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Queue closed: the connection was deregistered, so the socket goes with it.
|
||||||
|
let _ = session.close(None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reader(mut stream: AggregatedMessageStream, tx: UnboundedSender<Outbound>) {
|
||||||
|
let Some(id) = authenticate(&mut stream, &tx).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rate = RateLimiter::new();
|
||||||
|
loop {
|
||||||
|
let Some(frame) = stream.recv().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let payload = match frame {
|
||||||
|
Ok(AggregatedMessage::Binary(bytes)) => bytes,
|
||||||
|
Ok(AggregatedMessage::Ping(bytes)) => {
|
||||||
|
// WebSocket-level keepalive, unrelated to the protocol's Ping op. It is
|
||||||
|
// still proof the socket is alive, so it counts for the liveness window
|
||||||
|
// (the protocol ops are touched inside registry.handle).
|
||||||
|
rooms::registry().touch(id, Instant::now());
|
||||||
|
let _ = tx.send(Outbound::Pong(bytes.to_vec()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(AggregatedMessage::Pong(_)) => {
|
||||||
|
rooms::registry().touch(id, Instant::now());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(AggregatedMessage::Close(_)) => break,
|
||||||
|
Ok(AggregatedMessage::Text(_)) => {
|
||||||
|
// "binary frames only ... Text frames are a protocol error -> close".
|
||||||
|
println!("multi_live/ws: text frame from conn {}", id);
|
||||||
|
close(&tx, CLOSE_PROTOCOL);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("multi_live/ws: websocket error on conn {}: {}", id, err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
if !rate.allow(now) {
|
||||||
|
println!("multi_live/ws: conn {} exceeded {} msg/sec", id, RATE_LIMIT_PER_SEC);
|
||||||
|
close(&tx, CLOSE_RATE_LIMIT);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = match proto::decode_client(&payload) {
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(err) => {
|
||||||
|
println!("multi_live/ws: bad frame from conn {}: {}", id, err);
|
||||||
|
close(&tx, close_code_for(&err));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if matches!(msg, ClientMsg::Auth { .. }) {
|
||||||
|
// Auth is the first message and only the first message.
|
||||||
|
println!("multi_live/ws: repeat Auth on conn {}", id);
|
||||||
|
close(&tx, CLOSE_PROTOCOL);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
rooms::registry().handle(id, msg, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms::registry().disconnect(id, Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the mandatory first frame and registers the connection. Returns None when the
|
||||||
|
// connection was closed instead.
|
||||||
|
async fn authenticate(
|
||||||
|
stream: &mut AggregatedMessageStream,
|
||||||
|
tx: &UnboundedSender<Outbound>,
|
||||||
|
) -> Option<ConnId> {
|
||||||
|
let first = match rt::time::timeout(AUTH_TIMEOUT, stream.recv()).await {
|
||||||
|
Ok(Some(Ok(frame))) => frame,
|
||||||
|
Ok(Some(Err(_))) | Ok(None) => return None,
|
||||||
|
Err(_) => {
|
||||||
|
println!("multi_live/ws: no Auth within {}s", AUTH_TIMEOUT.as_secs());
|
||||||
|
close(tx, CLOSE_UNAUTHENTICATED);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let AggregatedMessage::Binary(payload) = first else {
|
||||||
|
// "First message on a connection MUST be Auth; anything else -> close 4001."
|
||||||
|
close(tx, CLOSE_UNAUTHENTICATED);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Ok(ClientMsg::Auth { user_id, token }) = proto::decode_client(&payload) else {
|
||||||
|
println!("multi_live/ws: first message was not a decodable Auth frame");
|
||||||
|
close(tx, CLOSE_UNAUTHENTICATED);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(uid) = resolve_user(&user_id, &token) else {
|
||||||
|
// Never print the token itself (it is the login credential); empty-vs-unknown is
|
||||||
|
// the diagnostic that matters: empty means the client sent no credential at all
|
||||||
|
// (the pre-fix Android builds sent UserSaveData.m_uuid, which device builds never
|
||||||
|
// populate), unknown means a credential the tokens table has no row for.
|
||||||
|
println!(
|
||||||
|
"multi_live/ws: rejecting uid {}: bad session ({})",
|
||||||
|
user_id,
|
||||||
|
if token.is_empty() { "empty token" } else { "unknown token" }
|
||||||
|
);
|
||||||
|
close(tx, CLOSE_UNAUTHENTICATED);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = rooms::registry().connect(uid, tx.clone(), Instant::now());
|
||||||
|
println!("multi_live/ws: auth ok, uid {} connected as conn {}", uid, id);
|
||||||
|
let _ = tx.send(Outbound::Msg(ServerMsg::AuthOk {
|
||||||
|
actorless_time_ms: rooms::server_time_ms(),
|
||||||
|
}));
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The relay validates the same credential the HTTP layer does. Over HTTP the login token
|
||||||
|
// arrives inside the `a6573cbe` header (global::get_login) and every handler then keys
|
||||||
|
// userdata off it; here it arrives as the Auth payload instead, and the tokens table maps
|
||||||
|
// it back to the account. The claimed userId only has to agree with the token, so a
|
||||||
|
// client cannot relay as somebody else - which is a little stricter than the HTTP layer,
|
||||||
|
// where an unknown token simply resolves to an empty account.
|
||||||
|
fn resolve_user(user_id: &str, token: &str) -> Option<i64> {
|
||||||
|
if token.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match_claim(user_id, userdata::uid_from_login_token(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The claim check, split out from the lookup so it can be tested without a database.
|
||||||
|
// `uid` is 0 when the token is unknown.
|
||||||
|
fn match_claim(user_id: &str, uid: i64) -> Option<i64> {
|
||||||
|
if uid == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match user_id.parse::<i64>() {
|
||||||
|
// A blank or unparsable userId is tolerated: the token is the authority.
|
||||||
|
Err(_) => Some(uid),
|
||||||
|
Ok(claimed) if claimed == 0 || claimed == uid => Some(uid),
|
||||||
|
Ok(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_code_for(err: &DecodeError) -> u16 {
|
||||||
|
// The spec names 4001 and 4002 only; every framing failure shares 4000.
|
||||||
|
match err {
|
||||||
|
DecodeError::Empty
|
||||||
|
| DecodeError::Truncated
|
||||||
|
| DecodeError::TrailingBytes
|
||||||
|
| DecodeError::BadUtf8
|
||||||
|
| DecodeError::UnknownOp(_)
|
||||||
|
| DecodeError::UnknownTag(_) => CLOSE_PROTOCOL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(tx: &UnboundedSender<Outbound>, code: u16) {
|
||||||
|
let _ = tx.send(Outbound::Close(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sliding one second window rather than a fixed bucket, so 30 messages either side of a
|
||||||
|
// second boundary still trips the cap.
|
||||||
|
struct RateLimiter {
|
||||||
|
seen: VecDeque<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
fn new() -> Self {
|
||||||
|
RateLimiter { seen: VecDeque::with_capacity(RATE_LIMIT_PER_SEC + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow(&mut self, now: Instant) -> bool {
|
||||||
|
while let Some(front) = self.seen.front() {
|
||||||
|
if now.duration_since(*front) >= RATE_WINDOW {
|
||||||
|
self.seen.pop_front();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.seen.len() >= RATE_LIMIT_PER_SEC {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.seen.push_back(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limiter_allows_the_cap_and_rejects_the_next() {
|
||||||
|
let mut rate = RateLimiter::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
for i in 0..RATE_LIMIT_PER_SEC {
|
||||||
|
assert!(rate.allow(start + Duration::from_millis(i as u64)), "message {} refused", i);
|
||||||
|
}
|
||||||
|
assert!(!rate.allow(start + Duration::from_millis(999)));
|
||||||
|
// The window slides: once the first message ages out there is room again.
|
||||||
|
assert!(rate.allow(start + Duration::from_millis(1001)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limiter_catches_a_burst_straddling_a_second_boundary() {
|
||||||
|
let mut rate = RateLimiter::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
// 29 messages at the end of one second...
|
||||||
|
for i in 0..RATE_LIMIT_PER_SEC - 1 {
|
||||||
|
assert!(rate.allow(start + Duration::from_millis(900 + i as u64)));
|
||||||
|
}
|
||||||
|
// ...and two more just after the boundary is still 31 inside one second.
|
||||||
|
assert!(rate.allow(start + Duration::from_millis(1000)));
|
||||||
|
assert!(!rate.allow(start + Duration::from_millis(1001)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_decode_failure_closes_with_the_protocol_code() {
|
||||||
|
for err in [
|
||||||
|
DecodeError::Empty,
|
||||||
|
DecodeError::Truncated,
|
||||||
|
DecodeError::TrailingBytes,
|
||||||
|
DecodeError::BadUtf8,
|
||||||
|
DecodeError::UnknownOp(200),
|
||||||
|
DecodeError::UnknownTag(9),
|
||||||
|
] {
|
||||||
|
assert_eq!(close_code_for(&err), CLOSE_PROTOCOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_needs_a_token_that_agrees_with_the_claimed_user() {
|
||||||
|
// An empty token never reaches the database.
|
||||||
|
assert_eq!(resolve_user("1", ""), None);
|
||||||
|
// An unknown token resolves to uid 0.
|
||||||
|
assert_eq!(match_claim("1", 0), None);
|
||||||
|
assert_eq!(match_claim("42", 42), Some(42));
|
||||||
|
// Claiming somebody else's account with your own token is refused.
|
||||||
|
assert_eq!(match_claim("43", 42), None);
|
||||||
|
// A blank, zero or unparsable userId defers to the token.
|
||||||
|
assert_eq!(match_claim("", 42), Some(42));
|
||||||
|
assert_eq!(match_claim("0", 42), Some(42));
|
||||||
|
assert_eq!(match_claim("not-a-number", 42), Some(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use jzon::{object, array};
|
use jzon::object;
|
||||||
use actix_web::{web, Responder};
|
use actix_web::{web, Responder};
|
||||||
|
|
||||||
use crate::router::Api;
|
use crate::router::Api;
|
||||||
@@ -15,5 +15,8 @@ async fn reward() -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn reward_post() -> impl Responder {
|
async fn reward_post() -> impl Responder {
|
||||||
Api(Some(array![]))
|
Api(Some(object!{
|
||||||
|
"gift_list": [],
|
||||||
|
"reward_list": []
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/router/rich_text.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// TextMeshPro rich-text guarding for user-supplied strings.
|
||||||
|
//
|
||||||
|
// Every name/description the custom-song and custom-card uploads accept is rendered by the game
|
||||||
|
// through TextMeshUI -> TMP with rich text ENABLED (the shipped labels carry m_isRichText: 1),
|
||||||
|
// and the client does NOT escape it: it hands user text straight to SetText, exactly as it does
|
||||||
|
// for other players' account names. So a song called "<size=400%>x", a card whose skill
|
||||||
|
// description carries <sprite=...>, or a character named "<font=nonexistent>" mangles or breaks
|
||||||
|
// every screen that shows it - for EVERY player, since public songs and published cards are
|
||||||
|
// visible to all. This is the in-game equivalent of stored XSS.
|
||||||
|
//
|
||||||
|
// The fix belongs here rather than at the render seam, because that is where official data draws
|
||||||
|
// the line: the shipped masterdata carries NO markup in any name or artist column (1102 of the
|
||||||
|
// 1103 tags in the whole EN music table are <br>, all of them inside detailInfo), <br> in the
|
||||||
|
// descriptive columns, and <size=NN> only in character nameRichtextGacha - a column whose name
|
||||||
|
// says it is meant to be rich text. Uploads are held to exactly that shape.
|
||||||
|
//
|
||||||
|
// A '<' that TMP would not read as a tag is left alone, so titles like "<3" still upload.
|
||||||
|
|
||||||
|
// The tags found in `text`, lowercased and without a leading '/'. Mirrors TMP's own scan: a tag
|
||||||
|
// opens at '<' followed by an optional '/' then a letter or '#' (a colour tag), and must close
|
||||||
|
// with '>' before the next '<'. Anything else is literal text.
|
||||||
|
fn tags(text: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] != '<' {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i + 1;
|
||||||
|
if j < chars.len() && chars[j] == '/' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
// Not tag-like: a bare "<3", "< 3", "<<"
|
||||||
|
if j >= chars.len() || !(chars[j].is_ascii_alphabetic() || chars[j] == '#') {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name_start = j;
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' && chars[j] != '=' && chars[j] != ' ' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let name: String = chars[name_start..j].iter().collect();
|
||||||
|
// The tag has to actually close before another one opens, or TMP prints it verbatim
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && chars[j] == '>' {
|
||||||
|
rv.push(name.to_lowercase());
|
||||||
|
i = j + 1;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject text carrying a rich-text tag the field is not supposed to have. `allowed` holds
|
||||||
|
// lowercase tag names without the slash, so listing "size" permits both <size=80> and </size>.
|
||||||
|
pub fn reject_tags(label: &str, text: &str, allowed: &[&str]) -> Result<(), String> {
|
||||||
|
for tag in tags(text) {
|
||||||
|
if !allowed.contains(&tag.as_str()) {
|
||||||
|
return Err(format!(
|
||||||
|
"{} may not contain the rich text tag <{}> - the game renders it as formatting and it would break the screens it appears on",
|
||||||
|
label, tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop every rich-text tag, keeping the text between them. For strings that are ALREADY stored
|
||||||
|
// and cannot be rejected at the point of use - an uploader's account name, which ew accepts
|
||||||
|
// verbatim on the profile route.
|
||||||
|
pub fn strip_tags(text: &str) -> String {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut rv = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] == '<' {
|
||||||
|
let mut j = i + 1;
|
||||||
|
if j < chars.len() && chars[j] == '/' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && (chars[j].is_ascii_alphabetic() || chars[j] == '#') {
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && chars[j] == '>' {
|
||||||
|
i = j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_real_tags_are_tags() {
|
||||||
|
// Literal text TMP never reads as formatting
|
||||||
|
for text in ["I <3 you", "a < b", "3 <", "<<", "1<2", "<b unclosed", "<b never closes"] {
|
||||||
|
assert!(reject_tags("Name", text, &[]).is_ok(), "{}", text);
|
||||||
|
}
|
||||||
|
// Everything TMP would format with
|
||||||
|
for text in ["<b>x", "x</b>", "<size=400%>x", "<sprite=1>", "<#ff0000>x", "<color=red>x",
|
||||||
|
"<font=\"none\">x", "<rotate=45>x", "<noparse>x", "<voffset=5em>x",
|
||||||
|
// the unclosed opener is literal, but the tag after it is not
|
||||||
|
"<b <i>x"] {
|
||||||
|
assert!(reject_tags("Name", text, &[]).is_err(), "{}", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowed_tags_pass_and_others_still_dont() {
|
||||||
|
assert!(reject_tags("Description", "line one<br>line two", &["br"]).is_ok());
|
||||||
|
assert!(reject_tags("Description", "line one<br>line two", &[]).is_err());
|
||||||
|
// The slash form of an allowed tag is allowed too
|
||||||
|
assert!(reject_tags("Gacha name", "<size=80>Mari</size>", &["size"]).is_ok());
|
||||||
|
assert!(reject_tags("Gacha name", "<size=80><sprite=3>", &["size"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_error_names_the_field_and_the_tag() {
|
||||||
|
let error = reject_tags("Song name", "<size=400%>boom", &[]).unwrap_err();
|
||||||
|
assert!(error.contains("Song name"), "{}", error);
|
||||||
|
assert!(error.contains("<size>"), "{}", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripping_keeps_the_words_and_the_harmless_angle_brackets() {
|
||||||
|
assert_eq!(strip_tags("<size=400%>Nozomi</size>"), "Nozomi");
|
||||||
|
assert_eq!(strip_tags("I <3 <b>you</b>"), "I <3 you");
|
||||||
|
assert_eq!(strip_tags("plain name"), "plain name");
|
||||||
|
assert_eq!(strip_tags("<b unclosed"), "<b unclosed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@ async fn start(req: HttpRequest, Session { key, body }: Session) -> impl Respond
|
|||||||
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); // todo - maybe compatibility layer?
|
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); // todo - maybe compatibility layer?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An account holding a runtime custom card (protocol 3) can't run on a
|
||||||
|
// client that only understands the baked band - card_list would carry ids
|
||||||
|
// it can't resolve
|
||||||
|
if !crate::router::custom_card::client_supports(&req) && crate::router::custom_card::account_supports(&key) {
|
||||||
|
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
|
||||||
|
}
|
||||||
|
|
||||||
global::api(&req, Some(object!{
|
global::api(&req, Some(object!{
|
||||||
"asset_hash": asset_hash,
|
"asset_hash": asset_hash,
|
||||||
"token": hex::encode("Hello") //what is this?
|
"token": hex::encode("Hello") //what is this?
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use jzon::{array, object, JsonValue};
|
use jzon::{array, object, JsonValue};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::collections::HashMap;
|
||||||
use crate::router::userdata;
|
use crate::router::userdata;
|
||||||
use crate::router::global;
|
use crate::router::global;
|
||||||
|
use crate::router::{card, custom_card, databases};
|
||||||
|
|
||||||
fn get_clear_count(user: &JsonValue, level: i32) -> i64 {
|
fn get_clear_count(user: &JsonValue, level: i32) -> i64 {
|
||||||
let mut rv = 0;
|
let mut rv = 0;
|
||||||
@@ -67,34 +70,97 @@ pub enum UserView {
|
|||||||
|
|
||||||
const DEFAULT_CARD: i64 = 10010001;
|
const DEFAULT_CARD: i64 = 10010001;
|
||||||
|
|
||||||
fn proxy_card_id(id: i64) -> i64 {
|
lazy_static! {
|
||||||
let prefix = id / 10000;
|
// Each character's lowest official card id: the stand-in shown to viewers
|
||||||
if prefix < 10000 {
|
// who can't resolve a custom card of that character
|
||||||
return id;
|
static ref OFFICIAL_CARD_BY_CHARACTER: HashMap<i64, i64> = {
|
||||||
|
let mut rv: HashMap<i64, i64> = HashMap::new();
|
||||||
|
for entry in databases::CARD_LIST.entries() {
|
||||||
|
let Some(id) = entry.1["id"].as_i64() else { continue; };
|
||||||
|
if card::is_custom(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(character) = entry.1["masterCharacterId"].as_i64() else { continue; };
|
||||||
|
let slot = rv.entry(character).or_insert(id);
|
||||||
|
if id < *slot {
|
||||||
|
*slot = id;
|
||||||
}
|
}
|
||||||
let rv = if prefix < 14000 {
|
|
||||||
(prefix - 9000) * 10000 + 1
|
|
||||||
} else {
|
|
||||||
DEFAULT_CARD
|
|
||||||
};
|
|
||||||
// Not every prefix has a real character behind it
|
|
||||||
if crate::router::databases::CARD_LIST[rv.to_string()].is_empty() {
|
|
||||||
return DEFAULT_CARD;
|
|
||||||
}
|
}
|
||||||
rv
|
rv
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn proxy_user_cards(user: &mut JsonValue) {
|
// 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() {
|
||||||
|
return card["masterCharacterId"].as_i64();
|
||||||
|
}
|
||||||
|
if custom_card::is_custom_runtime(id) {
|
||||||
|
return custom_card::character_of(id);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
let Some(character) = card_character(id) else {
|
||||||
|
return DEFAULT_CARD;
|
||||||
|
};
|
||||||
|
let Some(rv) = OFFICIAL_CARD_BY_CHARACTER.get(&character) else {
|
||||||
|
return DEFAULT_CARD;
|
||||||
|
};
|
||||||
|
*rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card row for a slot the account can't actually supply: level 1, unevolved.
|
||||||
|
// Only ever handed to viewers, never written back to the account
|
||||||
|
fn stand_in_card(master_card_id: i64) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
id: master_card_id,
|
||||||
|
master_card_id: master_card_id,
|
||||||
|
exp: 0,
|
||||||
|
skill_exp: 0,
|
||||||
|
evolve: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card object for one of the four favourite/guest slots. global::get_card
|
||||||
|
// hands back an empty object when the slot is 0 (never set) or names a card the
|
||||||
|
// account no longer holds, and an empty object reaches the client as
|
||||||
|
// master_card_id 0: Shock.CardData's constructor looks that up in masterdata and
|
||||||
|
// dereferences the row, so it throws before the guest cell is ever drawn. Same
|
||||||
|
// repair userdata::remove_deleted_custom_cards makes for a dead slot - the
|
||||||
|
// account's first card, then the default for an account holding none
|
||||||
|
fn slot_card(id: i64, user: &JsonValue) -> JsonValue {
|
||||||
|
let card = global::get_card(id, user);
|
||||||
|
if !card.is_empty() {
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
let first = &user["card_list"][0];
|
||||||
|
if !first.is_empty() {
|
||||||
|
return first.clone();
|
||||||
|
}
|
||||||
|
stand_in_card(DEFAULT_CARD)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) {
|
||||||
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
||||||
let id = user["user"][key].as_i64().unwrap_or(0);
|
let id = user["user"][key].as_i64().unwrap_or(0);
|
||||||
if crate::router::card::is_custom(id) {
|
if !custom_card::viewer_can_resolve(id, protocol) {
|
||||||
user["user"][key] = proxy_card_id(id).into();
|
user["user"][key] = proxy_card_id(id).into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A card's id is its master_card_id, so both sides need proxying
|
// A card's id is its master_card_id, so both sides need proxying
|
||||||
for key in ["favorite_card", "guest_smile_card", "guest_cool_card", "guest_pure_card"] {
|
for key in ["favorite_card", "guest_smile_card", "guest_cool_card", "guest_pure_card"] {
|
||||||
let id = user[key]["master_card_id"].as_i64().unwrap_or(0);
|
let id = user[key]["master_card_id"].as_i64().unwrap_or(0);
|
||||||
if crate::router::card::is_custom(id) {
|
if !custom_card::viewer_can_resolve(id, protocol) {
|
||||||
user[key]["id"] = proxy_card_id(id).into();
|
user[key]["id"] = proxy_card_id(id).into();
|
||||||
user[key]["master_card_id"] = proxy_card_id(id).into();
|
user[key]["master_card_id"] = proxy_card_id(id).into();
|
||||||
}
|
}
|
||||||
@@ -102,7 +168,8 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
|
|||||||
if !user["main_deck_detail"].is_empty() {
|
if !user["main_deck_detail"].is_empty() {
|
||||||
let mut used = array![];
|
let mut used = array![];
|
||||||
for id in user["main_deck_detail"]["deck"]["main_card_ids"].members_mut() {
|
for id in user["main_deck_detail"]["deck"]["main_card_ids"].members_mut() {
|
||||||
let card = proxy_card_id(id.as_i64().unwrap_or(0));
|
let raw = id.as_i64().unwrap_or(0);
|
||||||
|
let card = if custom_card::viewer_can_resolve(raw, protocol) { raw } else { proxy_card_id(raw) };
|
||||||
// Whole characters share one proxy, and the client can't hold the
|
// Whole characters share one proxy, and the client can't hold the
|
||||||
// same card twice
|
// same card twice
|
||||||
if card == 0 || used.contains(card) {
|
if card == 0 || used.contains(card) {
|
||||||
@@ -116,7 +183,7 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
|
|||||||
let mut ids = array![];
|
let mut ids = array![];
|
||||||
for card in user["main_deck_detail"]["card_list"].members() {
|
for card in user["main_deck_detail"]["card_list"].members() {
|
||||||
let id = card["master_card_id"].as_i64().unwrap_or(0);
|
let id = card["master_card_id"].as_i64().unwrap_or(0);
|
||||||
let proxy = proxy_card_id(id);
|
let proxy = if custom_card::viewer_can_resolve(id, protocol) { id } else { proxy_card_id(id) };
|
||||||
if ids.contains(proxy) {
|
if ids.contains(proxy) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -132,7 +199,7 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool) -> JsonValue {
|
pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) -> JsonValue {
|
||||||
let user = userdata::get_acc_from_uid(id);
|
let user = userdata::get_acc_from_uid(id);
|
||||||
if !user["error"].is_empty() {
|
if !user["error"].is_empty() {
|
||||||
return object!{};
|
return object!{};
|
||||||
@@ -140,12 +207,26 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool
|
|||||||
|
|
||||||
let mut rv = object!{
|
let mut rv = object!{
|
||||||
user: user["user"].clone(),
|
user: user["user"].clone(),
|
||||||
favorite_card: global::get_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user),
|
favorite_card: slot_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||||
guest_smile_card: global::get_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user),
|
guest_smile_card: slot_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||||
guest_cool_card: global::get_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user),
|
guest_cool_card: slot_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user),
|
||||||
guest_pure_card: global::get_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user)
|
guest_pure_card: slot_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The id fields have to name the same card as the objects: surfaces that
|
||||||
|
// resolve the id instead of the object (profile, friend detail) would
|
||||||
|
// otherwise look up the slot this just stood in for. A no-op for an account
|
||||||
|
// whose slots are all set
|
||||||
|
for (key, card) in [
|
||||||
|
("favorite_master_card_id", "favorite_card"),
|
||||||
|
("guest_smile_master_card_id", "guest_smile_card"),
|
||||||
|
("guest_cool_master_card_id", "guest_cool_card"),
|
||||||
|
("guest_pure_master_card_id", "guest_pure_card")
|
||||||
|
] {
|
||||||
|
let id = rv[card]["master_card_id"].clone();
|
||||||
|
rv["user"][key] = id;
|
||||||
|
}
|
||||||
|
|
||||||
if let UserView::Detail | UserView::Ranking = view {
|
if let UserView::Detail | UserView::Ranking = view {
|
||||||
rv["main_deck_detail"] = object!{
|
rv["main_deck_detail"] = object!{
|
||||||
total_power: 0,
|
total_power: 0,
|
||||||
@@ -182,9 +263,151 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !custom_cards {
|
proxy_user_cards(&mut rv, protocol);
|
||||||
proxy_user_cards(&mut rv);
|
|
||||||
}
|
|
||||||
|
|
||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::database::custom_card as db;
|
||||||
|
|
||||||
|
fn wipe(owner: i64) {
|
||||||
|
for card in db::get_cards_by_owner(owner).members() {
|
||||||
|
db::delete_card(card["master_card_id"].as_i64().unwrap());
|
||||||
|
}
|
||||||
|
for character in db::get_characters_by_owner(owner).members() {
|
||||||
|
db::delete_character(character["master_character_id"].as_i64().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
fn proxies_resolve_through_the_character() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(5101);
|
||||||
|
|
||||||
|
assert_eq!(proxy_card_id(10010001), 10010001);
|
||||||
|
assert_eq!(proxy_card_id(0), 0);
|
||||||
|
assert_eq!(proxy_card_id(100010001), 10010001);
|
||||||
|
assert_eq!(proxy_card_id(100090001), 10090001);
|
||||||
|
assert_eq!(proxy_card_id(110010001), 20010001);
|
||||||
|
assert_eq!(proxy_card_id(130010001), 40010001);
|
||||||
|
assert_eq!(proxy_card_id(140010001), DEFAULT_CARD);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
assert_eq!(proxy_card_id(unknown), DEFAULT_CARD);
|
||||||
|
// A proxy is never zero and always a real row
|
||||||
|
for id in [100010001i64, 141720001, 149990001, id, orphan, unknown] {
|
||||||
|
let proxy = proxy_card_id(id);
|
||||||
|
assert_ne!(proxy, 0, "id {}", id);
|
||||||
|
assert!(!databases::CARD_LIST[proxy.to_string()].is_empty(), "id {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
wipe(5101);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even a protocol-3 viewer gets a proxy for a draft - published is what
|
||||||
|
// makes a runtime card resolvable, and older viewers proxy everything
|
||||||
|
#[test]
|
||||||
|
fn drafts_and_old_viewers_get_proxies() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
wipe(5102);
|
||||||
|
|
||||||
|
let published = db::next_card_id();
|
||||||
|
db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false);
|
||||||
|
let draft = db::next_card_id();
|
||||||
|
db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false);
|
||||||
|
|
||||||
|
assert!(custom_card::viewer_can_resolve(published, custom_card::PROTOCOL_VERSION));
|
||||||
|
assert!(!custom_card::viewer_can_resolve(draft, custom_card::PROTOCOL_VERSION));
|
||||||
|
|
||||||
|
let mut user = jzon::object!{
|
||||||
|
"user": {
|
||||||
|
"favorite_master_card_id": draft,
|
||||||
|
"guest_smile_master_card_id": published
|
||||||
|
},
|
||||||
|
"favorite_card": { "id": draft, "master_card_id": draft },
|
||||||
|
"guest_smile_card": { "id": published, "master_card_id": published },
|
||||||
|
"main_deck_detail": {
|
||||||
|
"deck": { "main_card_ids": [draft, published, 10010001, 0, 0] },
|
||||||
|
"card_list": [
|
||||||
|
{ "id": draft, "master_card_id": draft },
|
||||||
|
{ "id": published, "master_card_id": published },
|
||||||
|
{ "id": 10010001, "master_card_id": 10010001 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
proxy_user_cards(&mut user, custom_card::PROTOCOL_VERSION);
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(20030001));
|
||||||
|
assert_eq!(user["user"]["guest_smile_master_card_id"].as_i64(), Some(published));
|
||||||
|
assert_eq!(user["favorite_card"]["master_card_id"].as_i64(), Some(20030001));
|
||||||
|
assert_eq!(user["guest_smile_card"]["master_card_id"].as_i64(), Some(published));
|
||||||
|
assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][0].as_i64(), Some(20030001));
|
||||||
|
assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][1].as_i64(), Some(published));
|
||||||
|
assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][2].as_i64(), Some(10010001));
|
||||||
|
assert_eq!(user["main_deck_detail"]["card_list"].len(), 3);
|
||||||
|
for card in user["main_deck_detail"]["card_list"].members() {
|
||||||
|
assert_ne!(card["master_card_id"].as_i64(), Some(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol 0-2 viewers proxy even the published runtime card
|
||||||
|
for protocol in [0u32, 1, 2] {
|
||||||
|
let mut user = jzon::object!{
|
||||||
|
"user": { "favorite_master_card_id": published },
|
||||||
|
"favorite_card": { "id": published, "master_card_id": published }
|
||||||
|
};
|
||||||
|
proxy_user_cards(&mut user, protocol);
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(20030001), "protocol {}", protocol);
|
||||||
|
}
|
||||||
|
// The baked import band needs only protocol 2
|
||||||
|
let mut user = jzon::object!{
|
||||||
|
"user": { "favorite_master_card_id": 100010001 },
|
||||||
|
"favorite_card": { "id": 100010001, "master_card_id": 100010001 }
|
||||||
|
};
|
||||||
|
proxy_user_cards(&mut user, 2);
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(100010001));
|
||||||
|
proxy_user_cards(&mut user, 1);
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(10010001));
|
||||||
|
|
||||||
|
wipe(5102);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't allow account downgrade (will crash client)
|
||||||
|
if !crate::router::custom_card::client_supports(&req) {
|
||||||
|
crate::router::custom_card::strip_unsupported(&mut user);
|
||||||
|
}
|
||||||
|
|
||||||
global::api(&req, Some(user))
|
global::api(&req, Some(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +183,7 @@ pub async fn announcement(Login(key): Login) -> impl Responder {
|
|||||||
let mut user = userdata::get_acc_home(&key);
|
let mut user = userdata::get_acc_home(&key);
|
||||||
|
|
||||||
user["home"]["new_announcement_flag"] = (0).into();
|
user["home"]["new_announcement_flag"] = (0).into();
|
||||||
|
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
|
||||||
|
|
||||||
userdata::save_acc_home(&key, user);
|
userdata::save_acc_home(&key, user);
|
||||||
|
|
||||||
@@ -243,12 +249,12 @@ async fn migration(Body(body): Body) -> impl Responder {
|
|||||||
|
|
||||||
async fn detail(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
async fn detail(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
|
||||||
let friends = userdata::get_acc_friends(&key);
|
let friends = userdata::get_acc_friends(&key);
|
||||||
let custom_cards = crate::router::card::client_supports_custom_cards(&req);
|
let protocol = crate::router::global::client_protocol_version(&req);
|
||||||
|
|
||||||
let mut user_detail_list = array![];
|
let mut user_detail_list = array![];
|
||||||
for data in body["user_ids"].members() {
|
for data in body["user_ids"].members() {
|
||||||
let uid = data.as_i64().unwrap();
|
let uid = data.as_i64().unwrap();
|
||||||
let user = guest::get_user(uid, &friends, guest::UserView::Detail, custom_cards);
|
let user = guest::get_user(uid, &friends, guest::UserView::Detail, protocol);
|
||||||
user_detail_list.push(user).unwrap();
|
user_detail_list.push(user).unwrap();
|
||||||
}
|
}
|
||||||
Api(Some(object!{
|
Api(Some(object!{
|
||||||
@@ -434,7 +440,10 @@ async fn initialize(Session { key, body }: Session) -> impl Responder {
|
|||||||
master_character_id: chosen_character,
|
master_character_id: chosen_character,
|
||||||
exp: 1
|
exp: 1
|
||||||
}];
|
}];
|
||||||
items::advance_mission(1158000 + crate::router::live::get_master_id(chosen_character), 1, 1500, &mut missions);
|
let bond = crate::router::live::bond_missions(chosen_character);
|
||||||
|
if !bond.is_empty() {
|
||||||
|
items::advance_mission(bond[0][1].as_i64().unwrap(), 1, bond[0][0].as_i64().unwrap(), &mut missions);
|
||||||
|
}
|
||||||
|
|
||||||
userdata::save_acc(&key, user.clone());
|
userdata::save_acc(&key, user.clone());
|
||||||
userdata::save_acc_chats(&key, chats);
|
userdata::save_acc_chats(&key, chats);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod starter;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
@@ -9,6 +10,7 @@ use crate::router::global;
|
|||||||
use crate::router::items;
|
use crate::router::items;
|
||||||
use crate::router::card;
|
use crate::router::card;
|
||||||
use crate::database::custom_song;
|
use crate::database::custom_song;
|
||||||
|
use crate::database::custom_card;
|
||||||
use crate::sql::SQLite;
|
use crate::sql::SQLite;
|
||||||
use crate::include_file;
|
use crate::include_file;
|
||||||
|
|
||||||
@@ -245,6 +247,13 @@ fn get_uid(token: &str) -> i64 {
|
|||||||
data.parse::<i64>().unwrap_or(0)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Needed by gree
|
// Needed by gree
|
||||||
pub fn get_login_token(uid: i64) -> String {
|
pub fn get_login_token(uid: i64) -> String {
|
||||||
let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid));
|
let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid));
|
||||||
@@ -332,10 +341,66 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
|
|||||||
true
|
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
|
||||||
|
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() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut candidates = array![];
|
||||||
|
for data in user["card_list"].members() {
|
||||||
|
let id = data["master_card_id"].as_i64().unwrap_or(0);
|
||||||
|
if id >= custom_card::FIRST_CARD_ID && id <= custom_card::LAST_CARD_ID && !candidates.contains(id) {
|
||||||
|
candidates.push(id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let dead = custom_card::dead_card_ids(&candidates);
|
||||||
|
if dead.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut i = 0;
|
||||||
|
while i < user["card_list"].len() {
|
||||||
|
if dead.contains(user["card_list"][i]["master_card_id"].as_i64().unwrap_or(0)) {
|
||||||
|
user["card_list"].array_remove(i);
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for deck in user["deck_list"].members_mut() {
|
||||||
|
for slot in deck["main_card_ids"].members_mut() {
|
||||||
|
if dead.contains(slot.as_i64().unwrap_or(0)) {
|
||||||
|
*slot = (0).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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)) {
|
||||||
|
user["user"][key] = fallback.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_acc(auth_key: &str) -> JsonValue {
|
pub fn get_acc(auth_key: &str) -> JsonValue {
|
||||||
let mut user = get_data(auth_key, "userdata");
|
let mut user = get_data(auth_key, "userdata");
|
||||||
cleanup_account(&mut user);
|
cleanup_account(&mut user);
|
||||||
let mut changed = remove_deleted_custom_songs(&mut user);
|
let mut changed = remove_deleted_custom_songs(&mut user);
|
||||||
|
if remove_deleted_custom_cards(&mut user) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if items::lp_modification(&mut user, 0, false) {
|
if items::lp_modification(&mut user, 0, false) {
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -418,6 +483,47 @@ pub fn save_acc_eventlogin(auth_key: &str, data: JsonValue) {
|
|||||||
pub fn save_server_data(auth_key: &str, data: JsonValue) {
|
pub fn save_server_data(auth_key: &str, data: JsonValue) {
|
||||||
save_data(auth_key, "server_data", data);
|
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| {
|
||||||
|
let raw: String = conn.query_row(
|
||||||
|
"SELECT server_data FROM server_data WHERE user_id=?1",
|
||||||
|
params!(key),
|
||||||
|
|row| row.get(0)
|
||||||
|
)?;
|
||||||
|
let mut data = jzon::parse(&raw).unwrap_or(JsonValue::Null);
|
||||||
|
let rv = f(&mut data);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE server_data SET server_data=?1 WHERE user_id=?2",
|
||||||
|
params!(jzon::stringify(data), key)
|
||||||
|
)?;
|
||||||
|
Ok(rv)
|
||||||
|
});
|
||||||
|
match rv {
|
||||||
|
Ok(rv) => rv,
|
||||||
|
Err(err) => {
|
||||||
|
println!("modify_server_data: {} for user {}", err, key);
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn save_acc_chats(auth_key: &str, data: JsonValue) {
|
pub fn save_acc_chats(auth_key: &str, data: JsonValue) {
|
||||||
save_data(auth_key, "chats", data);
|
save_data(auth_key, "chats", data);
|
||||||
}
|
}
|
||||||
@@ -682,6 +788,37 @@ pub fn export_user(token: &str) -> Option<JsonValue> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every row an account owns, gone. Factored out of purge_accounts so the arcade
|
||||||
|
// sweeper (a machine that aged out takes its two accounts with it) and the
|
||||||
|
// card-rebind orphan cleanup delete exactly the same set of rows - an account
|
||||||
|
// half-deleted here is one the login path would resurrect empty.
|
||||||
|
//
|
||||||
|
// This list is also what the arcade guest reset mirrors: starter::write_starter_rows
|
||||||
|
// rewrites the eleven data rows, re-draws the token and deletes the rest, so a
|
||||||
|
// row added here has to be accounted for there too or a guest starts carrying
|
||||||
|
// the previous player's state across a credit.
|
||||||
|
pub const ACCOUNT_TABLES: &[&str] = &[
|
||||||
|
"userdata", "userhome", "missions", "loginbonus", "sifcards", "friends",
|
||||||
|
"chats", "exchange", "event", "eventloginbonus", "server_data", "webui",
|
||||||
|
"tokens", "migration"
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn delete_account(user_id: i64) {
|
||||||
|
crate::database::gree::delete_uuid(user_id);
|
||||||
|
for table in ACCOUNT_TABLES {
|
||||||
|
DATABASE.lock_and_exec(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(user_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the account has ever registered a data-transfer password, which is
|
||||||
|
// the only way an account can be taken over from another device. The arcade uses
|
||||||
|
// it to tell a throwaway account it made itself from a real player's account.
|
||||||
|
pub fn has_transfer_password(user_id: i64) -> bool {
|
||||||
|
!DATABASE.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(user_id))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn purge_accounts() -> usize {
|
pub fn purge_accounts() -> usize {
|
||||||
// If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over.
|
// If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over.
|
||||||
let dead_uids = DATABASE.lock_and_select_all("
|
let dead_uids = DATABASE.lock_and_select_all("
|
||||||
@@ -697,21 +834,7 @@ pub fn purge_accounts() -> usize {
|
|||||||
for uid in dead_uids.members() {
|
for uid in dead_uids.members() {
|
||||||
let user_id = uid.as_i64().unwrap();
|
let user_id = uid.as_i64().unwrap();
|
||||||
println!("Removing dead UID: {}", user_id);
|
println!("Removing dead UID: {}", user_id);
|
||||||
crate::database::gree::delete_uuid(user_id);
|
delete_account(user_id);
|
||||||
DATABASE.lock_and_exec("DELETE FROM userdata WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM userhome WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM missions WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM loginbonus WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM sifcards WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM friends WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM chats WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM exchange WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM event WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM eventloginbonus WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM server_data WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM tokens WHERE user_id=?1", params!(user_id));
|
|
||||||
DATABASE.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(user_id));
|
|
||||||
}
|
}
|
||||||
DATABASE.lock_and_exec("VACUUM", params!());
|
DATABASE.lock_and_exec("VACUUM", params!());
|
||||||
crate::database::gree::setup();
|
crate::database::gree::setup();
|
||||||
@@ -777,4 +900,57 @@ mod tests {
|
|||||||
assert!(!stored["live_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
assert!(!stored["live_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
assert!(!stored["live_mission_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
assert!(!stored["live_mission_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User draws a custom card -> the card is deleted -> the next userdata
|
||||||
|
// pull drops the dead card from card_list, empties its deck slots and
|
||||||
|
// repoints favorite/guest references; unpublished-but-alive cards survive
|
||||||
|
#[test]
|
||||||
|
fn deleted_custom_card_records_are_wiped_on_pull() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let token = "userdata-card-test-token";
|
||||||
|
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);
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
for id in [deleted_id, unpublished_id] {
|
||||||
|
user["card_list"].push(jzon::object!{
|
||||||
|
"id": id,
|
||||||
|
"master_card_id": id,
|
||||||
|
"exp": 0,
|
||||||
|
"skill_exp": 0,
|
||||||
|
"evolve": [],
|
||||||
|
"created_date_time": 0
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
user["deck_list"][0]["main_card_ids"][0] = deleted_id.into();
|
||||||
|
user["deck_list"][0]["main_card_ids"][1] = unpublished_id.into();
|
||||||
|
user["user"]["favorite_master_card_id"] = deleted_id.into();
|
||||||
|
save_acc(token, user);
|
||||||
|
|
||||||
|
// Both cards still exist: nothing gets wiped
|
||||||
|
let user = get_acc(token);
|
||||||
|
assert!(user["card_list"].members().any(|data| data["master_card_id"] == deleted_id));
|
||||||
|
|
||||||
|
custom_card::delete_card(deleted_id);
|
||||||
|
|
||||||
|
// The next pull drops the dead card's records and only those
|
||||||
|
let user = get_acc(token);
|
||||||
|
assert!(!user["card_list"].members().any(|data| data["master_card_id"] == deleted_id));
|
||||||
|
assert!(user["card_list"].members().any(|data| data["master_card_id"] == unpublished_id));
|
||||||
|
assert_eq!(user["deck_list"][0]["main_card_ids"][0].as_i64(), Some(0));
|
||||||
|
assert_eq!(user["deck_list"][0]["main_card_ids"][1].as_i64(), Some(unpublished_id));
|
||||||
|
// The dead favorite repointed to the first remaining card
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), user["card_list"][0]["master_card_id"].as_i64());
|
||||||
|
|
||||||
|
// The wipe persisted to the database, not just the served copy
|
||||||
|
let stored = get_data(token, "userdata");
|
||||||
|
assert!(!stored["card_list"].members().any(|data| data["master_card_id"] == deleted_id));
|
||||||
|
|
||||||
|
custom_card::delete_card(unpublished_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
{
|
{
|
||||||
"point_ranking": {
|
"point_ranking": {
|
||||||
|
"rank": 0,
|
||||||
"point": 0
|
"point": 0
|
||||||
},
|
},
|
||||||
"score_ranking": [],
|
"score_ranking": {
|
||||||
"member_ranking": [],
|
"all_rank": 0,
|
||||||
"lottery_box": [],
|
"group_rank": 0,
|
||||||
|
"score": 0
|
||||||
|
},
|
||||||
|
"member_ranking": {
|
||||||
|
"master_character_id": 0,
|
||||||
|
"rank": 0,
|
||||||
|
"point": 0
|
||||||
|
},
|
||||||
|
"lottery_box": {
|
||||||
|
"master_lottery_id": 0,
|
||||||
|
"reset_count": 0,
|
||||||
|
"draw_count_list": []
|
||||||
|
},
|
||||||
"mission_list": [],
|
"mission_list": [],
|
||||||
"policy_agreement": 0,
|
"policy_agreement": 0,
|
||||||
"incentive_lottery": 0,
|
"incentive_lottery": 0,
|
||||||
|
"is_disconnected": 0,
|
||||||
|
"help_count": 0,
|
||||||
|
"penalty_remaining_time": 0,
|
||||||
"star_event": {
|
"star_event": {
|
||||||
"star_level": 0,
|
"star_level": 0,
|
||||||
"last_event_star_level": 0,
|
"last_event_star_level": 0,
|
||||||
|
|||||||
465
src/router/userdata/starter.rs
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
// Accounts that are born finished.
|
||||||
|
//
|
||||||
|
// Everything below Title in the client assumes a tutorial-complete account: a
|
||||||
|
// name, a favourite card, a nine-card deck, tutorial_step 130. The tutorial that
|
||||||
|
// produces one is four client requests, and nothing on the server can create
|
||||||
|
// that state on its own - which is exactly what the arcade needs, twice per
|
||||||
|
// cabinet (the machine identity and its guest) and again at every credit (the
|
||||||
|
// guest, rewritten in place).
|
||||||
|
//
|
||||||
|
// So this module replays those four requests' mutations, in their order, off
|
||||||
|
// their own handlers' code:
|
||||||
|
//
|
||||||
|
// POST /api/lottery the tutorial draw leaves a card in card_list,
|
||||||
|
// which is what /user/initialize reads as the
|
||||||
|
// account's "ur" (lottery.rs:342-358, user.rs:383)
|
||||||
|
// POST /api/user/initialize favourite + guest cards, 3000 gems, the band
|
||||||
|
// title, the nine base cards, deck slot 1,
|
||||||
|
// character_list, the first bond mission
|
||||||
|
// (user.rs:371-453)
|
||||||
|
// POST /api/user the player's name (user.rs:138-140)
|
||||||
|
// POST /api/tutorial tutorial_step 130 and full stamina
|
||||||
|
// (tutorial.rs:13-17)
|
||||||
|
//
|
||||||
|
// The one deliberate difference is the gacha: a cabinet draws nothing random.
|
||||||
|
// The chosen member's own base card takes the "ur" slot instead, which leaves
|
||||||
|
// the starter deck complete (nine distinct cards) rather than one short, and
|
||||||
|
// makes every arcade account byte-identical apart from its name.
|
||||||
|
//
|
||||||
|
// `write_starter_rows` is the single source of truth for what an account is made
|
||||||
|
// of, shared by creation and by the guest reset - the two can never drift.
|
||||||
|
//
|
||||||
|
// It is also the single source of truth for what an account is NOT made of. A
|
||||||
|
// guest is handed to a stranger every credit, so the reset has to leave behind
|
||||||
|
// exactly what a freshly created account has: the eleven data rows, one login
|
||||||
|
// token, and nothing else. Every other row `delete_account` recognises as
|
||||||
|
// account-owned (mod.rs:791) is deleted here rather than rewritten - the
|
||||||
|
// transfer code and password /api/user/registerpassword registers, the webui
|
||||||
|
// session, the gree device certificate - because each of them is a credential
|
||||||
|
// the previous player could keep and come back with. The login token is the one
|
||||||
|
// thing that is kept in the sense that the account keeps having one, but it is
|
||||||
|
// re-drawn too: the old one is on the cabinet's disk and may be on the previous
|
||||||
|
// player's phone, and /api/arcade/session hands the new one straight back to the
|
||||||
|
// cabinet in the `uuid` the client already adopts.
|
||||||
|
|
||||||
|
use jzon::{array, object, JsonValue};
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
use crate::include_file;
|
||||||
|
use crate::router::{card, chat, global, items, live};
|
||||||
|
use super::{DATABASE, NEW_USER, acc_exists, generate_uid};
|
||||||
|
|
||||||
|
// The member every arcade account is built around: Kousaka Honoka, the first
|
||||||
|
// member of the first band, so the deck is deterministic and recognisable.
|
||||||
|
// user/initialize derives everything else from this one id.
|
||||||
|
const STARTER_CHARACTER_ID: i64 = 1001;
|
||||||
|
|
||||||
|
// The nine cards /user/initialize rewards for a mu's pick (user.rs:398)
|
||||||
|
const STARTER_CARDS: &[i64] = &[
|
||||||
|
10010001, 10020001, 10030001, 10040001, 10050001,
|
||||||
|
10060001, 10070001, 10080001, 10090001
|
||||||
|
];
|
||||||
|
|
||||||
|
// The card the tutorial gacha would have left in card_list[0]. See the module
|
||||||
|
// note: the chosen member's own base card, so the deck comes out whole.
|
||||||
|
const STARTER_UR: i64 = 10010001;
|
||||||
|
|
||||||
|
// user.rs:396-411: 3000000 + the band offset (0 for mu's) + the member's index
|
||||||
|
// within the band, which is the last two digits of the character id
|
||||||
|
const STARTER_TITLE_ID: i64 = 3_000_000 + STARTER_CHARACTER_ID % 100;
|
||||||
|
|
||||||
|
// Every account name is stored verbatim by /api/user, so a cabinet name is no
|
||||||
|
// more dangerous than a player name - but it arrives from a machine that types
|
||||||
|
// it once and never again, so it is clamped rather than trusted to be sane.
|
||||||
|
pub const MAX_NAME_LEN: usize = 32;
|
||||||
|
|
||||||
|
pub fn clean_name(name: &str, fallback: &str) -> String {
|
||||||
|
let name: String = name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(MAX_NAME_LEN)
|
||||||
|
.collect();
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return fallback.to_string();
|
||||||
|
}
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The userdata blob a finished tutorial leaves behind, plus the two side rows it
|
||||||
|
// writes on the way (the mission progress and the chat it unlocks).
|
||||||
|
fn tutorial_complete(uid: i64, name: &str) -> (JsonValue, JsonValue, JsonValue, JsonValue) {
|
||||||
|
let now = global::timestamp();
|
||||||
|
|
||||||
|
// create_acc (mod.rs:230-232)
|
||||||
|
let mut user = NEW_USER.clone();
|
||||||
|
user["user"]["id"] = uid.into();
|
||||||
|
user["stamina"]["last_updated_time"] = now.into();
|
||||||
|
|
||||||
|
let mut home = jzon::parse(&include_file!("src/router/userdata/new_user_home.json")).unwrap();
|
||||||
|
let mut missions = jzon::parse(&include_file!("src/router/userdata/missions.json")).unwrap();
|
||||||
|
let mut chats = array![];
|
||||||
|
|
||||||
|
// --- POST /api/user/initialize (user.rs:371-453) ------------------------
|
||||||
|
chat::add_chat(STARTER_CHARACTER_ID, 1, &mut chats);
|
||||||
|
|
||||||
|
user["user"]["favorite_master_card_id"] = STARTER_UR.into();
|
||||||
|
user["user"]["guest_smile_master_card_id"] = STARTER_UR.into();
|
||||||
|
user["user"]["guest_cool_master_card_id"] = STARTER_UR.into();
|
||||||
|
user["user"]["guest_pure_master_card_id"] = STARTER_UR.into();
|
||||||
|
home["home"]["preset_setting"][0]["illust_master_card_id"] = STARTER_UR.into();
|
||||||
|
user["gem"]["free"] = (3000).into();
|
||||||
|
user["gem"]["total"] = (3000).into();
|
||||||
|
user["user"]["master_title_ids"][0] = STARTER_TITLE_ID.into();
|
||||||
|
|
||||||
|
// The clear-mission and chat out-parameters are thrown away here exactly as
|
||||||
|
// /user/initialize throws them away (user.rs:418): the only chat a fresh
|
||||||
|
// account keeps is the one added above.
|
||||||
|
for id in STARTER_CARDS {
|
||||||
|
items::give_character(*id, &mut user, &mut missions, &mut array![], &mut array![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut others = array![];
|
||||||
|
for id in STARTER_CARDS {
|
||||||
|
if id / 10000 != STARTER_CHARACTER_ID {
|
||||||
|
others.push(*id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for slot in 0..9 {
|
||||||
|
let card_id = if slot == 4 {
|
||||||
|
STARTER_UR
|
||||||
|
} else if slot < 4 {
|
||||||
|
others[slot].as_i64().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
others[slot - 1].as_i64().unwrap_or(0)
|
||||||
|
};
|
||||||
|
user["deck_list"][0]["main_card_ids"][slot] = card_id.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
user["character_list"] = array![object!{
|
||||||
|
master_character_id: STARTER_CHARACTER_ID,
|
||||||
|
exp: 1
|
||||||
|
}];
|
||||||
|
let bond = live::bond_missions(STARTER_CHARACTER_ID);
|
||||||
|
if !bond.is_empty() {
|
||||||
|
items::advance_mission(bond[0][1].as_i64().unwrap(), 1, bond[0][0].as_i64().unwrap(), &mut missions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- POST /api/user (user.rs:138-140) -----------------------------------
|
||||||
|
user["user"]["name"] = name.into();
|
||||||
|
|
||||||
|
// --- POST /api/tutorial, the final step (tutorial.rs:13-17) -------------
|
||||||
|
user["tutorial_step"] = (130).into();
|
||||||
|
user["stamina"]["stamina"] = (100).into();
|
||||||
|
user["stamina"]["last_updated_time"] = now.into();
|
||||||
|
|
||||||
|
(user, home, missions, chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything create_acc seeds, as (table, value). The column always carries the
|
||||||
|
// table's own name; `userdata` is the one row with extra columns and is written
|
||||||
|
// by the caller.
|
||||||
|
fn side_rows(chats: &JsonValue, home: &JsonValue, missions: &JsonValue) -> Vec<(&'static str, String)> {
|
||||||
|
let now = global::timestamp();
|
||||||
|
vec![
|
||||||
|
("userhome", jzon::stringify(home.clone())),
|
||||||
|
("missions", jzon::stringify(missions.clone())),
|
||||||
|
("chats", jzon::stringify(chats.clone())),
|
||||||
|
("loginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
|
||||||
|
("eventloginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
|
||||||
|
("sifcards", String::from("[]")),
|
||||||
|
("friends", String::from(r#"{"friend_user_id_list":[],"request_user_id_list":[],"pending_user_id_list":[]}"#)),
|
||||||
|
("event", String::from("{}")),
|
||||||
|
("exchange", String::from("[]")),
|
||||||
|
("server_data", format!(r#"{{"server_time_set":{},"server_time":1709272800}}"#, now))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows delete_account (mod.rs:791) removes that write_starter_rows does not
|
||||||
|
// write back: everything an account owns that is a credential rather than
|
||||||
|
// progress. `tokens` is not here because the starter write issues a fresh one in
|
||||||
|
// the same transaction, and `userdata` and its ten side tables are not here
|
||||||
|
// because they are rewritten. Anything added to delete_account's list belongs in
|
||||||
|
// one of those three places or the guest reset starts leaking again.
|
||||||
|
const CARRIED_CREDENTIAL_TABLES: &[&str] = &["migration", "webui"];
|
||||||
|
|
||||||
|
// Writes the whole account inside one transaction: the eleven data rows back to
|
||||||
|
// the starter state, every carried-over credential gone, and a login token
|
||||||
|
// nobody has seen before. Upserts rather than inserts, so the same code both
|
||||||
|
// creates an account and rewrites an existing one - the reset can never
|
||||||
|
// half-apply, and it cannot miss a table that creation seeds because there is
|
||||||
|
// only one list. Returns the account's new login token.
|
||||||
|
fn write_starter_rows(uid: i64, name: &str) -> Result<String, rusqlite::Error> {
|
||||||
|
let (user, home, missions, chats) = tutorial_complete(uid, name);
|
||||||
|
let rows = side_rows(&chats, &home, &missions);
|
||||||
|
let friend_request_disabled = user["user"]["friend_request_disabled"].as_i32().unwrap_or(1);
|
||||||
|
let protocol_version = if card::account_has_custom_cards(&user) { card::PROTOCOL_VERSION } else { 0 };
|
||||||
|
let userdata = jzon::stringify(user);
|
||||||
|
let token = global::create_token();
|
||||||
|
|
||||||
|
DATABASE.lock_and_transact(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO userdata (user_id, userdata, friend_request_disabled, protocol_version) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET userdata=?2, friend_request_disabled=?3, protocol_version=?4",
|
||||||
|
params!(uid, &userdata, friend_request_disabled, protocol_version)
|
||||||
|
)?;
|
||||||
|
for (table, value) in &rows {
|
||||||
|
conn.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO {0} (user_id, {0}) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET {0}=?2",
|
||||||
|
table
|
||||||
|
),
|
||||||
|
params!(uid, value)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for table in CARRIED_CREDENTIAL_TABLES {
|
||||||
|
conn.execute(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(uid))?;
|
||||||
|
}
|
||||||
|
// The account's one login token, re-drawn. The DELETE by token is
|
||||||
|
// create_acc's own collision guard (mod.rs:238); the DELETE by user id is
|
||||||
|
// what makes the INSERT a rotation rather than a primary-key conflict.
|
||||||
|
conn.execute("DELETE FROM tokens WHERE token=?1", params!(&token))?;
|
||||||
|
conn.execute("DELETE FROM tokens WHERE user_id=?1", params!(uid))?;
|
||||||
|
conn.execute("INSERT INTO tokens (user_id, token) VALUES (?1, ?2)", params!(uid, &token))?;
|
||||||
|
Ok(token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand new account, already through the tutorial. Returns its user id and its
|
||||||
|
// login token - the uuid the client stores and authenticates with from then on.
|
||||||
|
pub fn create(name: &str) -> Option<(i64, String)> {
|
||||||
|
let uid = generate_uid();
|
||||||
|
match write_starter_rows(uid, name) {
|
||||||
|
Ok(token) => Some((uid, token)),
|
||||||
|
Err(err) => {
|
||||||
|
println!("arcade: could not create account {}: {}", uid, err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrites an existing account back to the starter state, keeping its user id:
|
||||||
|
// the cabinet's guest, at the start of every credit. Returns the account's new
|
||||||
|
// login token, which /api/arcade/session answers with - the previous player's
|
||||||
|
// copy of the old one stops working the moment their credit ends.
|
||||||
|
pub fn reset(uid: i64, name: &str) -> Option<String> {
|
||||||
|
if !acc_exists(uid) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = match write_starter_rows(uid, name) {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(err) => {
|
||||||
|
println!("arcade: could not reset account {}: {}", uid, err);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The gree device certificate is the one account-owned credential that lives
|
||||||
|
// in another database, so it cannot ride the transaction above - but it is on
|
||||||
|
// delete_account's list for the same reason the two tables are, and a stale
|
||||||
|
// one would still name this account (database/gree.rs:98).
|
||||||
|
crate::database::gree::delete_uuid(uid);
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::router::userdata;
|
||||||
|
|
||||||
|
fn stored(uid: i64) -> JsonValue {
|
||||||
|
jzon::parse(&DATABASE.lock_and_select("SELECT userdata FROM userdata WHERE user_id=?1", params!(uid)).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(uid: i64, table: &str) -> String {
|
||||||
|
DATABASE.lock_and_select(&format!("SELECT {0} FROM {0} WHERE user_id=?1", table), params!(uid)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A created account is already past the tutorial: named, nine cards, a full
|
||||||
|
// deck with the favourite in the centre, gems, title, bond and full stamina
|
||||||
|
#[test]
|
||||||
|
fn a_created_account_is_tutorial_complete() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let (uid, token) = create("Cabinet 1").unwrap();
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&token), uid);
|
||||||
|
|
||||||
|
let user = stored(uid);
|
||||||
|
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
|
||||||
|
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
|
||||||
|
// 130 is what every gate downstream tests for (live.rs:92, :385, :435)
|
||||||
|
assert_eq!(user["tutorial_step"].as_i64(), Some(130));
|
||||||
|
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
|
||||||
|
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
|
||||||
|
assert_eq!(user["gem"]["total"].as_i64(), Some(3000));
|
||||||
|
assert_eq!(user["user"]["master_title_ids"][0].as_i64(), Some(3000001));
|
||||||
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(STARTER_UR));
|
||||||
|
assert_eq!(user["character_list"][0]["master_character_id"].as_i64(), Some(STARTER_CHARACTER_ID));
|
||||||
|
|
||||||
|
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
|
||||||
|
for id in STARTER_CARDS {
|
||||||
|
assert!(user["card_list"].members().any(|c| c["master_card_id"] == *id), "missing {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deck is whole: nine distinct cards, the favourite in the centre
|
||||||
|
let deck = &user["deck_list"][0]["main_card_ids"];
|
||||||
|
assert_eq!(deck.len(), 9);
|
||||||
|
assert_eq!(deck[4].as_i64(), Some(STARTER_UR));
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for slot in deck.members() {
|
||||||
|
let id = slot.as_i64().unwrap();
|
||||||
|
assert!(id != 0, "empty deck slot");
|
||||||
|
assert!(!seen.contains(&id), "duplicate card {} in the deck", id);
|
||||||
|
seen.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The home row carries the favourite too, and every side row exists
|
||||||
|
assert_eq!(jzon::parse(&row(uid, "userhome")).unwrap()["home"]["preset_setting"][0]["illust_master_card_id"].as_i64(), Some(STARTER_UR));
|
||||||
|
assert_eq!(row(uid, "sifcards"), "[]");
|
||||||
|
assert_eq!(row(uid, "exchange"), "[]");
|
||||||
|
assert_eq!(row(uid, "event"), "{}");
|
||||||
|
assert!(!jzon::parse(&row(uid, "missions")).unwrap().is_empty());
|
||||||
|
assert!(!jzon::parse(&row(uid, "chats")).unwrap().is_empty());
|
||||||
|
|
||||||
|
// The account is NOT one of the dead ones the purge sweeper collects
|
||||||
|
// (mod.rs:792-801): it has cards, a real name and step 130
|
||||||
|
assert!(!user["card_list"].is_empty());
|
||||||
|
assert_ne!(user["user"]["name"].as_str(), Some("Tutorial in progress"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guest reset keeps the identity and the token and throws away
|
||||||
|
// everything the last player did with it
|
||||||
|
#[test]
|
||||||
|
fn a_reset_keeps_the_identity_and_wipes_the_progress() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let (uid, token) = create("GUEST").unwrap();
|
||||||
|
|
||||||
|
// A credit's worth of play, on every row a reset has to reach
|
||||||
|
let mut user = stored(uid);
|
||||||
|
user["user"]["name"] = "Somebody".into();
|
||||||
|
user["user"]["exp"] = (12345).into();
|
||||||
|
user["stamina"]["stamina"] = (3).into();
|
||||||
|
user["gem"]["free"] = (99999).into();
|
||||||
|
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 7, high_score: 654321, max_combo: 300 }).unwrap();
|
||||||
|
user["live_mission_list"].push(object!{ master_live_id: 1100101, clear_master_live_mission_ids: [1, 2] }).unwrap();
|
||||||
|
user["item_list"].push(object!{ id: 17001001, master_item_id: 17001001, amount: 50 }).unwrap();
|
||||||
|
user["card_list"].push(object!{ id: 10010013, master_card_id: 10010013, exp: 0, skill_exp: 0, evolve: [], created_date_time: 0 }).unwrap();
|
||||||
|
userdata::save_acc(&token, user);
|
||||||
|
userdata::save_acc_friends(&token, object!{
|
||||||
|
friend_user_id_list: [1],
|
||||||
|
request_user_id_list: [],
|
||||||
|
pending_user_id_list: []
|
||||||
|
});
|
||||||
|
userdata::save_server_data(&token, object!{ last_live_started: [object!{ master_live_id: 1100101 }] });
|
||||||
|
userdata::save_acc_exchange(&token, array![1, 2, 3]);
|
||||||
|
|
||||||
|
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
|
||||||
|
|
||||||
|
// Same account, a new login token - the cabinet is handed the new one in
|
||||||
|
// the /api/arcade/session answer, and the previous player's copy of the
|
||||||
|
// old one now names nothing
|
||||||
|
assert_ne!(new_token, token, "the guest's login token was reused");
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&token), 0, "the previous credit's token still logs in");
|
||||||
|
|
||||||
|
let user = stored(uid);
|
||||||
|
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
|
||||||
|
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
|
||||||
|
assert_eq!(user["user"]["exp"].as_i64(), Some(0));
|
||||||
|
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
|
||||||
|
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
|
||||||
|
assert_eq!(user["live_list"].len(), 0);
|
||||||
|
assert_eq!(user["live_mission_list"].len(), 0);
|
||||||
|
assert_eq!(user["item_list"].len(), 0);
|
||||||
|
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
|
||||||
|
assert_eq!(user["deck_list"][0]["main_card_ids"][4].as_i64(), Some(STARTER_UR));
|
||||||
|
|
||||||
|
// Every side row went back too
|
||||||
|
assert_eq!(jzon::parse(&row(uid, "friends")).unwrap()["friend_user_id_list"].len(), 0);
|
||||||
|
assert!(jzon::parse(&row(uid, "server_data")).unwrap()["last_live_started"].is_null());
|
||||||
|
assert_eq!(row(uid, "exchange"), "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resetting something that is not an account is refused rather than
|
||||||
|
// conjuring one out of nothing
|
||||||
|
#[test]
|
||||||
|
fn resetting_an_unknown_account_is_refused() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
assert!(reset(123, "Cabinet 1").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guest is handed to a stranger every credit, so a reset has to take every
|
||||||
|
// credential the last player could have left on it - not just the progress.
|
||||||
|
// The list is delete_account's (mod.rs:791) minus the rows the starter write
|
||||||
|
// rewrites and the token it re-draws.
|
||||||
|
#[test]
|
||||||
|
fn a_reset_takes_every_credential_the_last_player_left() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let (uid, token) = create("GUEST").unwrap();
|
||||||
|
|
||||||
|
// The previous player, on the cabinet during their credit: a transfer
|
||||||
|
// code and password (/api/user/registerpassword), a webui session logged
|
||||||
|
// in with them, and a gree device certificate.
|
||||||
|
let code = userdata::user::migration::save_acc_transfer(uid, "hunter2");
|
||||||
|
assert!(userdata::has_transfer_password(uid));
|
||||||
|
let webui_token = userdata::webui_login(uid, "hunter2").expect("the webui login was refused");
|
||||||
|
assert_eq!(userdata::webui_login_token(&webui_token).as_deref(), Some(token.as_str()));
|
||||||
|
crate::database::gree::update_cert(uid, "a device certificate");
|
||||||
|
assert!(crate::database::gree::get_user_cert(&token).is_some());
|
||||||
|
|
||||||
|
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
|
||||||
|
|
||||||
|
// The transfer code and password are gone: neither the game's own
|
||||||
|
// takeover check nor the webui login answers to them any more
|
||||||
|
assert!(!userdata::has_transfer_password(uid), "the transfer password survived the reset");
|
||||||
|
assert!(!userdata::user::migration::get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap_or(false),
|
||||||
|
"the previous player's transfer code still takes the account over");
|
||||||
|
assert!(userdata::webui_login(uid, "hunter2").is_err(), "the previous player can still log the webui in");
|
||||||
|
|
||||||
|
// The webui session they left open is gone too
|
||||||
|
assert!(userdata::webui_login_token(&webui_token).is_none(), "the previous player's webui session survived the reset");
|
||||||
|
assert!(userdata::webui_get_user(&webui_token).is_none());
|
||||||
|
|
||||||
|
// So is the device certificate, which named the account from a phone
|
||||||
|
assert!(crate::database::gree::get_user_cert(&token).is_none(), "the gree certificate survived the reset");
|
||||||
|
assert!(crate::database::gree::get_user_cert(&new_token).is_none());
|
||||||
|
|
||||||
|
// And the account is still perfectly usable under its new token
|
||||||
|
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
|
||||||
|
assert_eq!(stored(uid)["user"]["name"].as_str(), Some("Cabinet 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every table delete_account clears is either rewritten by the starter write,
|
||||||
|
// re-drawn (the token) or deleted by it. A row added to delete_account without
|
||||||
|
// a decision here is a leak across credits, so the lists are compared rather
|
||||||
|
// than trusted to have been kept in step.
|
||||||
|
#[test]
|
||||||
|
fn the_reset_accounts_for_every_table_a_deletion_clears() {
|
||||||
|
let rewritten = ["userdata", "userhome", "missions", "chats", "loginbonus",
|
||||||
|
"eventloginbonus", "sifcards", "friends", "event", "exchange", "server_data"];
|
||||||
|
for table in userdata::ACCOUNT_TABLES {
|
||||||
|
assert!(
|
||||||
|
rewritten.contains(table) || CARRIED_CREDENTIAL_TABLES.contains(table) || *table == "tokens",
|
||||||
|
"delete_account clears {} and the guest reset does nothing about it",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// And the side-row list really is what the reset rewrites
|
||||||
|
let rows = side_rows(&array![], &object!{}, &object!{});
|
||||||
|
for (table, _) in &rows {
|
||||||
|
assert!(rewritten.contains(table), "{} is written by the reset but not listed above", table);
|
||||||
|
}
|
||||||
|
assert_eq!(rows.len() + 1, rewritten.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabinet names arrive from an operator typing into a machine
|
||||||
|
#[test]
|
||||||
|
fn names_are_clamped_not_trusted() {
|
||||||
|
assert_eq!(clean_name(" Cabinet 1 ", "ARCADE"), "Cabinet 1");
|
||||||
|
assert_eq!(clean_name("", "ARCADE"), "ARCADE");
|
||||||
|
assert_eq!(clean_name(" ", "ARCADE"), "ARCADE");
|
||||||
|
assert_eq!(clean_name("a\nb\tc", "ARCADE"), "abc");
|
||||||
|
assert_eq!(clean_name(&"x".repeat(200), "ARCADE").len(), MAX_NAME_LEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,12 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
|
|||||||
object!{success: false}
|
object!{success: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
|
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
|
||||||
let database = userdata::get_userdata_database();
|
let database = userdata::get_userdata_database();
|
||||||
let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {
|
let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {
|
||||||
|
|||||||
@@ -1,6 +1,513 @@
|
|||||||
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() { None } else { Some(key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_set(req: &HttpRequest) -> HashSet<i64> {
|
||||||
|
match player_key(req) {
|
||||||
|
Some(key) => { println!("Player has key"); userdata::get_acc_home(&key)["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect()},
|
||||||
|
None => { println!("No player key"); 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ use actix_web::{
|
|||||||
http::header::HeaderValue,
|
http::header::HeaderValue,
|
||||||
http::header::ContentType
|
http::header::ContentType
|
||||||
};
|
};
|
||||||
use jzon::{JsonValue, object};
|
use jzon::{array, JsonValue, object};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use include_dir::{include_dir, Dir};
|
use include_dir::{include_dir, Dir};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use crate::include_file;
|
use crate::include_file;
|
||||||
|
use crate::database::permissions;
|
||||||
use crate::router::{userdata, items};
|
use crate::router::{userdata, items};
|
||||||
use crate::router::databases::csv::Region;
|
use crate::router::databases::csv::Region;
|
||||||
|
|
||||||
@@ -30,6 +31,12 @@ pub fn get_login_token(req: &HttpRequest) -> Option<String> {
|
|||||||
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
|
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_uid(req: &HttpRequest) -> Option<i64> {
|
||||||
|
let token = get_login_token(req)?;
|
||||||
|
let login_token = userdata::webui_login_token(&token)?;
|
||||||
|
userdata::get_acc(&login_token)["user"]["id"].as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn error(msg: &str) -> HttpResponse {
|
pub fn error(msg: &str) -> HttpResponse {
|
||||||
let resp = object!{
|
let resp = object!{
|
||||||
result: "ERR",
|
result: "ERR",
|
||||||
@@ -221,6 +228,9 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
|
|||||||
data: {
|
data: {
|
||||||
account_import: get_config()["import"].as_bool().unwrap(),
|
account_import: get_config()["import"].as_bool().unwrap(),
|
||||||
custom_songs: !crate::router::custom_song::disabled(),
|
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: {
|
links: {
|
||||||
global: args.global_android,
|
global: args.global_android,
|
||||||
japan: args.japan_android,
|
japan: args.japan_android,
|
||||||
@@ -358,6 +368,278 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
|
|||||||
.body(jzon::stringify(ITEM.clone()))
|
.body(jzon::stringify(ITEM.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref CHARACTER_CHOICES: JsonValue = {
|
||||||
|
let mut rv = jzon::array![];
|
||||||
|
for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
|
||||||
|
let Some(id) = row["id"].as_i64() else { continue; };
|
||||||
|
rv.push(object!{
|
||||||
|
id: id,
|
||||||
|
name: row["name"].clone(),
|
||||||
|
name_en: row["nameEn"].clone(),
|
||||||
|
category: if id >= 5000 { "imported" } else { "official" }
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
};
|
||||||
|
|
||||||
|
static ref SKILL_CENTER_CHOICES: JsonValue = {
|
||||||
|
let mut en_rows = object!{};
|
||||||
|
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
|
||||||
|
en_rows[row["id"].to_string()] = row.clone();
|
||||||
|
}
|
||||||
|
let mut rv = jzon::array![];
|
||||||
|
for row in crate::router::databases::csv::table(Region::Jp, "skill_center").members() {
|
||||||
|
let en = &en_rows[row["id"].to_string()];
|
||||||
|
rv.push(object!{
|
||||||
|
id: row["id"].clone(),
|
||||||
|
name: row["name"].clone(),
|
||||||
|
name_en: en["name"].clone(),
|
||||||
|
detail_text: row["detailText"].clone(),
|
||||||
|
detail_text_en: en["detailText"].clone()
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_characters(req: HttpRequest) -> HttpResponse {
|
||||||
|
let Some(uid) = session_uid(&req) else {
|
||||||
|
return error("Not logged in");
|
||||||
|
};
|
||||||
|
let mut characters = CHARACTER_CHOICES.clone();
|
||||||
|
if !crate::router::custom_card::disabled() {
|
||||||
|
for character in crate::database::custom_card::get_selectable_characters(uid).members() {
|
||||||
|
characters.push(object!{
|
||||||
|
id: character["master_character_id"].clone(),
|
||||||
|
name: character["name"].clone(),
|
||||||
|
name_en: character["name_en"].clone(),
|
||||||
|
category: "custom"
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
characters: characters
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
|
||||||
|
if session_uid(&req).is_none() {
|
||||||
|
return error("Not logged in");
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
skill_centers: SKILL_CENTER_CHOICES.clone()
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
|
||||||
|
if session_uid(&req).is_none() {
|
||||||
|
return error("Not logged in");
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
data: crate::router::custom_card::upload_limits()
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
data: {
|
||||||
|
uid: 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_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),
|
||||||
|
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()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_permissions(req: HttpRequest) -> HttpResponse {
|
||||||
|
let Some(uid) = session_uid(&req) else {
|
||||||
|
return error("Not logged in");
|
||||||
|
};
|
||||||
|
let can_grant = permissions::has(uid, permissions::PERMISSION_GRANT);
|
||||||
|
let can_revoke = permissions::has(uid, permissions::PERMISSION_REVOKE);
|
||||||
|
if !can_grant && !can_revoke {
|
||||||
|
return error("You do not have permission to manage scopes");
|
||||||
|
}
|
||||||
|
let mut available = array![];
|
||||||
|
for scope in permissions::SCOPES {
|
||||||
|
available.push(*scope).unwrap();
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
data: {
|
||||||
|
uid: uid,
|
||||||
|
can_grant: can_grant,
|
||||||
|
can_revoke: can_revoke,
|
||||||
|
scopes: permissions::get_user_permissions(uid),
|
||||||
|
available: available,
|
||||||
|
grants: permissions::grants()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant_permission(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 target = body["uid"].as_i64().unwrap_or(0);
|
||||||
|
let scope = body["scope"].to_string();
|
||||||
|
if userdata::get_login_token(target) == String::new() {
|
||||||
|
return error(&format!("User {} does not exist", target));
|
||||||
|
}
|
||||||
|
if let Err(e) = permissions::grant(target, &scope, uid) {
|
||||||
|
return error(&e);
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK"
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_permission(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!{});
|
||||||
|
if let Err(e) = permissions::revoke(body["uid"].as_i64().unwrap_or(0), &body["scope"].to_string(), uid) {
|
||||||
|
return error(&e);
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK"
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator's cabinet list. Owner scope only: a machine row names the two
|
||||||
|
// accounts a cabinet owns, and removing one deletes them.
|
||||||
|
pub fn list_arcade_machines(req: HttpRequest) -> HttpResponse {
|
||||||
|
if crate::router::arcade::disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = session_uid(&req) else {
|
||||||
|
return error("Not logged in");
|
||||||
|
};
|
||||||
|
if !permissions::has(uid, permissions::ALL) {
|
||||||
|
return error("You do not have permission to manage arcade machines");
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
data: {
|
||||||
|
machines: crate::router::arcade::webui_machines()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse {
|
||||||
|
if crate::router::arcade::disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = session_uid(&req) else {
|
||||||
|
return error("Not logged in");
|
||||||
|
};
|
||||||
|
if !permissions::has(uid, permissions::ALL) {
|
||||||
|
return error("You do not have permission to manage arcade machines");
|
||||||
|
}
|
||||||
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||||
|
if let Err(e) = crate::router::arcade::webui_remove_machine(body["machine_id"].as_str().unwrap_or("")) {
|
||||||
|
return error(&e);
|
||||||
|
}
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK"
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The account page's "bind arcade card" form. The cabinet's own endpoint speaks
|
||||||
|
// the encrypted game protocol behind the asset gate, which a browser cannot, so
|
||||||
|
// this is the browser's door onto the same rule - arcade::bind_card, not a copy
|
||||||
|
// of it. A webui session is required on top of the transfer code and password:
|
||||||
|
// the page is behind login anyway, and the extra proof costs nothing.
|
||||||
|
pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse {
|
||||||
|
if crate::router::arcade::disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
if session_uid(&req).is_none() {
|
||||||
|
return error("Not logged in");
|
||||||
|
}
|
||||||
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||||
|
match crate::router::arcade::bind_card(
|
||||||
|
body["card_id"].as_str().unwrap_or(""),
|
||||||
|
body["migrationCode"].as_str().unwrap_or(""),
|
||||||
|
body["pass"].as_str().unwrap_or("")
|
||||||
|
) {
|
||||||
|
Ok(user_id) => {
|
||||||
|
let resp = object!{
|
||||||
|
result: "OK",
|
||||||
|
data: {
|
||||||
|
user_id: user_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
},
|
||||||
|
Err(reason) => error(&reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
||||||
let token = get_login_token(&req);
|
let token = get_login_token(&req);
|
||||||
if token.is_none() {
|
if token.is_none() {
|
||||||
@@ -397,3 +679,37 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
|
|||||||
.insert_header(ContentType::json())
|
.insert_header(ContentType::json())
|
||||||
.body(jzon::stringify(resp))
|
.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::*;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
#[test]
|
||||||
|
fn character_and_skill_center_choices_are_wellformed() {
|
||||||
|
assert!(CHARACTER_CHOICES.len() > 200, "got {}", CHARACTER_CHOICES.len());
|
||||||
|
let honoka = CHARACTER_CHOICES.members().find(|row| row["id"] == 1001).unwrap();
|
||||||
|
assert_eq!(honoka["name_en"].as_str(), Some("Honoka Kosaka"));
|
||||||
|
assert_eq!(honoka["category"].as_str(), Some("official"));
|
||||||
|
let imported = CHARACTER_CHOICES.members().find(|row| row["id"] == 5153).unwrap();
|
||||||
|
assert_eq!(imported["category"].as_str(), Some("imported"));
|
||||||
|
for row in CHARACTER_CHOICES.members() {
|
||||||
|
assert!(row["id"].as_i64().unwrap() > 0);
|
||||||
|
assert!(!row["name"].to_string().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(SKILL_CENTER_CHOICES.len() > 60, "got {}", SKILL_CENTER_CHOICES.len());
|
||||||
|
let first = SKILL_CENTER_CHOICES.members().find(|row| row["id"] == 100001).unwrap();
|
||||||
|
assert_eq!(first["name_en"].as_str(), Some("Smile Heart"));
|
||||||
|
assert_eq!(first["detail_text_en"].as_str(), Some("Smile points increased by 3%"));
|
||||||
|
assert!(!first["name"].to_string().is_empty());
|
||||||
|
assert!(!first["detail_text"].to_string().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::fs;
|
|||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref RUNNING: RwLock<bool> = RwLock::new(false);
|
static ref RUNNING: RwLock<bool> = RwLock::new(false);
|
||||||
static ref DATAPATH: RwLock<String> = RwLock::new(String::new());
|
static ref DATAPATH: RwLock<String> = RwLock::new(String::new());
|
||||||
|
static ref OWNERS: RwLock<Vec<i64>> = RwLock::new(Vec::new());
|
||||||
static ref MASTERDATA_PATH: RwLock<String> = RwLock::new(String::new());
|
static ref MASTERDATA_PATH: RwLock<String> = RwLock::new(String::new());
|
||||||
static ref MASTERDATA_WARNED: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
|
static ref MASTERDATA_WARNED: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
|
||||||
static ref EASTER: RwLock<bool> = RwLock::new(false);
|
static ref EASTER: RwLock<bool> = RwLock::new(false);
|
||||||
@@ -23,6 +24,8 @@ pub struct HostConfig {
|
|||||||
pub jp_android_asset_hash: String,
|
pub jp_android_asset_hash: String,
|
||||||
pub en_android_asset_hash: String,
|
pub en_android_asset_hash: String,
|
||||||
pub enable_custom_songs: bool,
|
pub enable_custom_songs: bool,
|
||||||
|
pub enable_custom_cards: bool,
|
||||||
|
pub enable_custom_3dmv: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
|
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
|
||||||
@@ -31,6 +34,26 @@ pub fn set_enable_custom_songs(enabled: bool) {
|
|||||||
HOST_CONFIG.write().unwrap().enable_custom_songs = enabled;
|
HOST_CONFIG.write().unwrap().enable_custom_songs = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
pub fn update_owners(uids: &[i64]) {
|
||||||
|
let mut w = OWNERS.write().unwrap();
|
||||||
|
*w = uids.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_owners() -> Vec<i64> {
|
||||||
|
OWNERS.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_running(running: bool) {
|
pub fn set_running(running: bool) {
|
||||||
let mut w = RUNNING.write().unwrap();
|
let mut w = RUNNING.write().unwrap();
|
||||||
*w = running;
|
*w = running;
|
||||||
@@ -144,11 +167,17 @@ pub fn overlay_args(args: &mut crate::options::Args) {
|
|||||||
}
|
}
|
||||||
overlay_str!(jp_android_asset_hash);
|
overlay_str!(jp_android_asset_hash);
|
||||||
overlay_str!(en_android_asset_hash);
|
overlay_str!(en_android_asset_hash);
|
||||||
// Overlay only ever enables the feature; a command-line --enable-custom-songs
|
// Overlay only ever enables the features; a command-line --enable-custom-*
|
||||||
// is never overridden back to off
|
// flag is never overridden back to off
|
||||||
if cfg.enable_custom_songs {
|
if cfg.enable_custom_songs {
|
||||||
args.enable_custom_songs = true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// idk why an ai put tests here but they are here now. Yay tests????
|
// idk why an ai put tests here but they are here now. Yay tests????
|
||||||
@@ -167,7 +196,10 @@ lazy_static! {
|
|||||||
pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
|
pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
|
||||||
let guard = crate::lock_onto_mutex!(TEST_LOCK);
|
let guard = crate::lock_onto_mutex!(TEST_LOCK);
|
||||||
update_data_path(&TEST_DATA_DIR);
|
update_data_path(&TEST_DATA_DIR);
|
||||||
// The feature is off by default; tests exercise it, so turn it on while holding the lock
|
// The features are off by default; tests exercise them, so turn them on
|
||||||
|
// while holding the lock
|
||||||
set_enable_custom_songs(true);
|
set_enable_custom_songs(true);
|
||||||
|
set_enable_custom_cards(true);
|
||||||
|
set_enable_custom_3dmv(true);
|
||||||
guard
|
guard
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/sql.rs
@@ -59,4 +59,28 @@ impl SQLite {
|
|||||||
}
|
}
|
||||||
Ok(rv)
|
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 tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||||
|
let rv = f(&tx)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(rv)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
web_assets/announcement/jquery-3.6.0.min.js
vendored
Normal file
BIN
web_assets/announcement/news_banner_generic_news.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_bg_badge.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
web_assets/announcement/news_bg_badge_eff.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
web_assets/announcement/news_btn_bulk.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
465
web_assets/announcement/news_common.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
BIN
web_assets/announcement/news_icon_event.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
web_assets/announcement/news_icon_gacha.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_icon_maintenance.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web_assets/announcement/news_icon_new.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web_assets/announcement/news_icon_news.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
web_assets/announcement/news_icon_others.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
web_assets/announcement/news_icon_shop.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
web_assets/announcement/news_img_arrow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
550
web_assets/announcement/sanitize.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||