Compare commits

20 Commits

Author SHA1 Message Date
Ethan O'Brien
1d12a9415c Oops 2026-08-23 16:09:49 -05:00
Ethan O'Brien
4dcf73e5d4 New asset version I guess 2026-08-23 15:25:14 -05:00
Ethan O'Brien
06c9273151 New asset hashes I guess I hope nothing breaks 2026-08-17 15:22:51 -05:00
Ethan O'Brien
d975b87799 Oh 2026-08-15 22:20:14 -05:00
Ethan O'Brien
cb40d74c2c Announcements working 2026-08-12 17:21:52 -05:00
Ethan O'Brien
8f7346d09e New asset hashes 2026-08-11 21:47:28 -05:00
Ethan O'Brien
0df2741251 Change log 2026-08-11 18:17:53 -05:00
Ethan O'Brien
cf05ffe8fb New asset hashes 2026-08-11 15:36:03 -05:00
Ethan O'Brien
27d7b8de82 Multi live 2026-08-11 14:41:28 -05:00
Ethan O'Brien
75613dafb0 Add some more checks 2026-08-05 20:34:17 -05:00
Ethan O'Brien
239a47b8ce New asset hashes 2026-08-02 16:41:31 -05:00
Ethan O'Brien
214ff68335 New asset hashes 2026-08-02 09:08:32 -05:00
Ethan O'Brien
582d56c301 Forgot to push module 2026-08-01 21:49:57 -05:00
Ethan O'Brien
0b0bef7bfb Fix some injection stuff 2026-08-01 21:35:26 -05:00
Ethan O'Brien
29ed7a1b46 Fix some custom song stuff 2026-08-01 20:37:03 -05:00
Ethan O'Brien
b4cd6968d8 Show public custom song titles on the clear rate page 2026-08-01 12:09:49 -05:00
Ethan O'Brien
ee1a3d827e Clamp custom skill values to official ranges, migrate pre-fix chart groupings 2026-08-01 10:24:03 -05:00
Ethan O'Brien
f75891dea0 Add custom character voicelines 2026-07-31 21:16:23 -05:00
Ethan O'Brien
26926e1a28 Add custom card handling probably 2026-07-31 20:32:02 -05:00
Ethan O'Brien
d077210efe Fix a thing 2026-07-31 19:46:29 -05:00
68 changed files with 14882 additions and 213 deletions

7
.gitignore vendored
View File

@@ -11,3 +11,10 @@ docker/data/
ndk/
.DS_Store
custom_songs/
custom_cards/
/custom_3dmv/
# local-only trees — never commit (35GB between them)
/android/
/assets.bak/
/assets.old/

19
Cargo.lock generated
View File

@@ -223,6 +223,22 @@ dependencies = [
"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]]
name = "adler2"
version = "2.0.1"
@@ -1073,6 +1089,7 @@ version = "1.1.0"
dependencies = [
"actix-multipart",
"actix-web",
"actix-ws",
"aes",
"argon2",
"base64",
@@ -1103,6 +1120,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"tokio",
"ureq",
"urlencoding",
"uuid",
@@ -3209,6 +3227,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]

View File

@@ -32,6 +32,10 @@ include_dir = "0.7.4"
jzon = "0.12.5"
csv = "1.3"
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"
image = "0.25"
zip = { version = "8.6", default-features = false, features = ["deflate"] }

View File

@@ -14,6 +14,9 @@ services:
DISABLE_IMPORTS: false # Will disable account imports
DISABLE_EXPORTS: false # Will disable account exports
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
#ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
#ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints)
#OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards
#PURGE: false # Purge dead user accounts on startup
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)

View File

@@ -14,6 +14,8 @@ args=(
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
add_opt() {
local value="$1" flag="$2"
@@ -30,6 +32,9 @@ add_opt "${EN_ANDROID_ASSET_HASH:-}" --en-android-asset-hash
add_opt "${EN_IOS_ASSET_HASH:-}" --en-ios-asset-hash
add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
# Server owner uid(s) for the permission system (comma-separated)
add_opt "${OWNER:-}" --owner
# Asset / image paths.
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
add_opt "${MASTERDATA:-}" --masterdata

View File

@@ -1,2 +1,6 @@
pub mod gree;
pub mod custom_song;
pub mod custom_card;
pub mod custom_3dmv;
pub mod permissions;
pub mod announcements;

View 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"));
}
}

217
src/database/custom_3dmv.rs Normal file
View 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
View 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);
}
}

View File

@@ -239,6 +239,32 @@ pub fn export_allowed(music_id: i64, viewer: Option<i64>) -> Result<(), &'static
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 {
DATABASE.lock_and_select_all("
SELECT music_id FROM songs
@@ -285,6 +311,14 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
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
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()

332
src/database/permissions.rs Normal file
View 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);
}
}
}

View File

@@ -27,12 +27,22 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
let args = get_args();
let port = args.port;
runtime::update_owners(&args.owner);
if args.purge {
println!("Purging accounts...");
let ct = crate::router::userdata::purge_accounts();
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()
//.wrap(Cors::permissive())
.wrap_fn(|req, srv| {

View File

@@ -41,6 +41,15 @@ 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")]
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, 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")]
pub purge: bool,

View File

@@ -10,6 +10,7 @@ pub mod home;
pub mod lottery;
pub mod friend;
pub mod live;
pub mod multi_live;
pub mod event;
pub mod chat;
pub mod story;
@@ -21,6 +22,9 @@ pub mod web;
pub mod card;
pub mod shop;
pub mod custom_song;
pub mod custom_card;
pub mod custom_3dmv;
pub mod rich_text;
pub mod webui;
pub mod clear_rate;
pub mod exchange;
@@ -217,11 +221,12 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/import" => webui::import(req, body),
"/api/webui/set_time" => webui::set_time(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_req(req, body).await
}
} else {
match req.path() {
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req),
@@ -231,6 +236,12 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/listMusic" => webui::get_music_info(req),
"/api/webui/listLoginBonus" => webui::list_login_bonus(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_req(req, body).await
}
}
@@ -253,6 +264,8 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(card::routes)
.configure(chat::routes)
.configure(custom_song::routes)
.configure(custom_card::routes)
.configure(custom_3dmv::routes)
.configure(debug::routes)
.configure(event::routes)
.configure(exchange::routes)
@@ -264,6 +277,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(login::routes)
.configure(lottery::routes)
.configure(mission::routes)
.configure(multi_live::routes)
.configure(notice::routes)
.configure(purchase::routes)
.configure(serial_code::routes)
@@ -279,4 +293,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(gree::routes)
);
cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes);
cfg.configure(custom_3dmv::web_routes);
cfg.configure(web::routes);
}

View File

@@ -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)))
}
// 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 {
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 curve = card["masterCardLevelId"].as_i64().unwrap_or(0);
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 {
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 skill_curve = databases::CARD_RARITY[&rarity]["masterCardSkillLevelId"].to_string();
databases::CARD_SKILL_MAX[skill_curve].as_i64().unwrap_or(i64::MAX)

View File

@@ -7,11 +7,33 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/chat")
.route("/home", web::post().to(home))
.route("/talk/get_stamp", web::get().to(get_stamp))
.route("/talk/start", web::post().to(start))
.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 {
for data in chats.members() {
if data["chat_id"] == id && data["room_id"] == num {
@@ -48,7 +70,7 @@ async fn home(Login(key): Login) -> impl Responder {
Api(Some(object!{
"progress_list": chats,
"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": []
}))
}
@@ -75,3 +97,41 @@ async fn end(Session { key, body }: Session) -> impl Responder {
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());
}
}

View File

@@ -68,14 +68,14 @@ fn setup_tables(conn: &rusqlite::Connection) {
);").unwrap();
}
fn update_live_score(id: i64, uid: i64, score: i64) {
if uid == 0 || score == 0 {
return;
}
let info = DATABASE.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(id)).unwrap_or(String::from("[]"));
let scores = jzon::parse(&info).unwrap();
// Merges this play into the song's top-10 board. Pure so the whole read-modify-write can
// sit inside one transaction, and so the keep-best rule is testable on its own.
//
// 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
// scores is higher — a replay that does not beat the stored one is dropped (`None`), which
// 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 current = 0;
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 {
return;
// Already on the board with a better score — keep it, drop this play.
return None;
}
if scores[i]["user"].as_i64().unwrap() == uid {
continue;
@@ -107,13 +108,41 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
result.push(scores[i].clone()).unwrap();
current += 1;
}
if added {
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));
}
if added { Some(result) } else { None }
}
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();
}
// 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) {
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));
},
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!(
let Some(column) = clear_rate_column(level, failed) else {
return;
};
// `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,
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 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 {
@@ -173,9 +224,22 @@ fn get_song_title(live_id: i32, english: bool) -> String {
if !details.is_null() {
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")
}
// 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn get_pass_percent(failed: i64, pass: i64) -> String {
let total = (failed + pass) as f64;
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 {
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 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() {
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 {
// The client wants the fields get_user hides from other players
let mut self_user = object!{
user: userdata::get_acc_from_uid(uid)["user"].clone()
};
if !custom_cards {
guest::proxy_user_cards(&mut self_user);
}
guest::proxy_user_cards(&mut self_user, protocol);
self_user["user"].clone()
} else {
user["user"].clone()
@@ -317,8 +379,8 @@ fn get_html() -> JsonValue {
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
};
let title_jp = get_song_title(info.live_id, false);
let title_en = get_song_title(info.live_id, true);
let title_jp = html_escape(&get_song_title(info.live_id, false));
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 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())
.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

File diff suppressed because it is too large Load Diff

View 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(())
}

View 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

File diff suppressed because it is too large Load Diff

View 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"));
}
}

View File

@@ -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;
// 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;
use jzon::{array, object, JsonValue};
@@ -11,7 +16,7 @@ use std::collections::HashMap;
use std::fs;
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::runtime::get_data_path;
use crate::lock_onto_mutex;
@@ -97,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder {
return Api(None);
}
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!{
"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()
}
// 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 {
if disabled() {
return array![];
@@ -126,6 +152,18 @@ pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
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 {
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))
}
// 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 {
if disabled() {
return HttpResponse::NotFound().finish();
@@ -293,15 +393,20 @@ fn process_jacket(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
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!{
"cue_name": cue_name,
"md5": cue.md5.clone(),
"size": cue.bytes.len(),
"duration_sec": cue.duration_sec as f32,
"is_loop": true,
"is_loop": is_loop,
"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())
}
// Score thresholds when the uploader doesn't provide any: take the highest
// difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10)
// (~200 points per note, scaled up for harder charts), then C/B/A/S at
// 50%/75%/100%/130% of base. Multi live thresholds are 1.2x the solo ones.
fn default_scores(full_combo: i64, level_number: i64) -> (JsonValue, JsonValue) {
let base = full_combo as f64 * 200.0 * (1.0 + level_number as f64 / 10.0);
let score = |mult: f64| (base * mult) as u32;
// Score rank thresholds. These are NOT per-song in SIF2: all 637 rows of the official live
// masterdata carry the exact same C/B/A/S tuple, solo and multi alike (live.csv columns
// _scoreC.._multiScoreS), because the score depends on deck strength rather than chart size.
// Deriving them from the chart's note count instead made rank S trivial on a short custom
// chart and unreachable on a long one, and skewed everything else that reads them - the live
// score gauge and the deck-confirm score estimation both scale off _scoreS/_multiScoreS
// (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!{
"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!{
"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> {
let name = field_str(fields, "name");
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));
}
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");
if visibility.is_empty() {
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"))?;
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 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_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap();
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
let (_, _, hardest_combo, _, _) = charts.last().unwrap();
let (score, multi_score) = default_scores();
// The upload metadata in the multipart-field schema, kept alongside the
// 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,
"score": score,
"multi_score": multi_score,
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
"mission_combo": mission_combo(*hardest_combo),
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
"jacket_md5": jacket_md5,
"jacket_size": jacket_size,
@@ -462,8 +653,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
"levels": levels,
"sound": {
"cue_sheet": format!("song_{}_{}", music_id, suffix),
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix)),
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix))
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix), false),
"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));
}
// 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)
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, 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)
};
// 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 mut levels = array![];
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
// the same formulas as upload
let (_, _, hardest_combo, hardest_stars) = charts.last().unwrap();
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
let (_, _, hardest_combo, _) = charts.last().unwrap();
let (score, multi_score) = default_scores();
let mut manifest_levels = array![];
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
let mut sound = old_song["sound"].clone();
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 {
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix));
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix), true);
}
let song = object!{
@@ -693,8 +912,8 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
"end_wait": 0.0,
"score": score,
"multi_score": multi_score,
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
"mission_combo": mission_combo(*hardest_combo),
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
"jacket_md5": jacket_md5,
"jacket_size": jacket_size,
@@ -770,7 +989,6 @@ async fn upload(req: HttpRequest, payload: Multipart) -> HttpResponse {
}
}
}
println!("UPLOAD5");
match create_song(uid, &fields) {
Ok(music_id) => send_json(object!{
result: "OK",
@@ -919,11 +1137,18 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
}
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::bump_revision();
// Global clear-rate stats for the dead live id (per-user score records are
// wiped lazily on each user's next userdata pull)
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)));
// 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));
}
}
drop(lock);
send_json(object!{
result: "OK"
@@ -1014,6 +1240,344 @@ mod tests {
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
// must be identical apart from the assigned music_id - INCLUDING the audio
// 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!(database::get_revision(), revision + 1);
// Adding a difficulty re-derives scores from the new hardest chart,
// and the manifest follows so exports reflect the edited state
// Adding a difficulty re-derives the combo missions from the new hardest chart, the
// score thresholds stay on the official constants, and the manifest follows so
// exports reflect the edited state
let mut fields = HashMap::new();
fields.insert(String::from("chart_4"), test_chart());
field(&mut fields, "level_number_4", "12");
update_song(music_id, &fields).unwrap();
let song = database::get_song(music_id).unwrap();
assert_eq!(song["levels"].len(), 2);
let (score, _) = default_scores(3, 12);
let (score, _) = default_scores();
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, "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();
@@ -1158,6 +1725,60 @@ mod tests {
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, &not_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(&not_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(&not_an_ogg);
let _ = fs::remove_file(&bad_name);
}
// mp3/wav uploads still work: symphonia decodes them and the play cue is
// transcoded to ogg-vorbis in-process
#[test]
@@ -1399,7 +2020,15 @@ mod tests {
field(&mut fields, "attribute", "1");
fields.insert(String::from("jacket"), test_png());
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 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);
}
// 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 &lt;Song&gt; &amp; &quot;Co&quot;"), "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
// 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

View File

@@ -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
// 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

View File

@@ -43,15 +43,92 @@ use jzon::{object, JsonValue};
// - 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; force_sync_group_id stays 0.
// the chain id above and not emitted.
// - 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
// simultaneously (equal timing_sec, which covers SIF1 effect 2 pairs) share one num.
// header occupies 100 and real groups count up from 101. The client spawns markers one
// 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
// deserializes it verbatim.
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
// line (the game counts a same-lane hold as one combo for the chain)
// 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 {
time: f64,
line: i64,
@@ -183,16 +260,33 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
let mut ids = 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 last_time = f64::NEG_INFINITY;
for (i, index) in order.iter().enumerate() {
ids[*index] = (i + 1) as i64;
// Simultaneous notes share a spawn group
if work[*index].time != last_time {
num += 1;
last_time = work[*index].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;
}
nums[*index] = num;
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;
for index in chunk {
nums[*index] = num;
force_sync[*index] = prev_num;
}
prev_num = num;
}
start = end;
}
let mut notes = jzon::array![{
@@ -221,7 +315,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
"child_id": if let Some(child) = note.child { ids[child] } else { 0 },
"child_num": if let Some(child) = note.child { nums[child] } 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();
}
@@ -233,6 +327,113 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
}, 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(&notes[*b].1).then(notes[*a].0.cmp(&notes[*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(&notes[*b].1).then(notes[*a].0.cmp(&notes[*b].0)));
let mut nums: Vec<(i64, i64)> = Vec::new();
let mut num = 100;
let mut last_time = f64::NEG_INFINITY;
for index in order {
if notes[index].1 != last_time {
num += 1;
last_time = notes[index].1;
}
nums.push((notes[index].0, num));
}
let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0);
for data in chart["notes"].members_mut().skip(1) {
data["num"] = lookup(data["id"].as_i64().unwrap()).into();
data["force_sync_group_id"] = 0.into();
let child = data["child_id"].as_i64().unwrap_or(0);
if child != 0 {
data["child_num"] = lookup(child).into();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -324,6 +525,73 @@ mod tests {
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]
fn parallel_pair() {
let beatmap = jzon::array![
@@ -337,6 +605,83 @@ mod tests {
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
assert_eq!(chart["notes"][1]["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]
@@ -535,6 +880,94 @@ mod tests {
}
}
// The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing
// transcode output reproduces the old encoding exactly (one num per cluster, no force
// links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included.
#[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]
fn rejects_bad_charts() {
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());

View 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);
}
}

View File

@@ -302,6 +302,57 @@ lazy_static! {
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 USER_RANK_REWARD: JsonValue = {

View File

@@ -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 is_star_event = STAR_EVENT_IDS.contains(&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();
}
normalise_event_shape(&mut event[event_id.to_string()]);
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);
// 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 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 {
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 scores = crate::router::event_ranking::get_scores_json().await[master_event_id as usize].clone();
let mut rv = array![];
@@ -232,9 +315,7 @@ async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder {
for score in scores.members() {
if i >= start && start + body["count"].as_u32().unwrap() >= i {
let mut entry = score.clone();
if !custom_cards {
crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"]);
}
crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"], protocol);
rv.push(entry).unwrap();
i += 1;
}
@@ -256,10 +337,14 @@ fn get_star_rank(points: i64) -> i64 {
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;
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;
let new_amount = data["amount"].as_i64().unwrap() + amount;
if new_amount > LIMIT_COINS {
@@ -280,7 +365,7 @@ fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
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() {
if data["type"] == 1 && data["master_event_id"] == event_id {
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 {
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);
}
}

View File

@@ -82,7 +82,9 @@ fn get_json() -> JsonValue {
let mut i = 1;
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!{
"rank": i,
"user_detail": user,

View File

@@ -21,7 +21,7 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
}
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 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() {
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();
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 {
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 friends = userdata::get_acc_friends(&key);
@@ -67,7 +67,7 @@ async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder {
let mut rv = array![];
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() {
continue;
}
@@ -84,7 +84,7 @@ async fn search(req: HttpRequest, Session { key, body }: Session) -> impl Respon
let friends = userdata::get_acc_friends(&key);
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() {
array![]

View File

@@ -23,18 +23,32 @@ struct AssetVersion {
static ASSET_VERSIONS: &[AssetVersion] = &[
// Default / stock
AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: true },
AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: true },
AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: false },
AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: false },
AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: false },
// Re-written client versions 2.0.0 - 2.1.2 (windows only)
AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "4ed1d077df2d1b29e17d25d64fb37242", latest: false },
// Re-written client versions 2.2.0 -
AssetVersion { region: "JP", platform: "Windows", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "ec508163f04e0f9e0435b4302011d123", latest: true },
// Re-written client versions 2.2.0 - 2.2.2
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: "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 },
];
@@ -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)
}
// 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) {
let mut edited = false;
let default_time = 1709272800;

View File

@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
let body = jzon::parse(&body).unwrap();
let migration_code = body["migration_code"].to_string();
let password = decrypt_transfer_password(&body["migration_password"].to_string());
let user = userdata::user::migration::get_acc_transfer(&body["migration_code"].to_string(), &password);
let user = userdata::user::migration::get_acc_transfer(&migration_code, &password);
let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
// The gree error envelope REQUIRES `code` and `message` on a failure. The old
// {result:"ERR", messsage:"User Not Found"} shape had neither (and misspelt the key),
// which breaks both native gamelibs:
// * iOS: +[GGLError errorWithJson:] builds
// @{NSLocalizedDescriptionKey: message} with message == nil ->
// NSInvalidArgumentException "attempt to insert nil object from objects[0]" -> the app
// hard-crashes the moment a wrong password / unknown code is entered on the new device.
// (It also reads `code` as [nil integerValue] == 0 == SUCCESS, so a non-crashing build
// would have run the success path on an empty payload.)
// * managed: Shock.GGL.Payment.GGLVerifyMigrationCode.CanRetry(code) tests
// code == 7004 (kGGLPErrorVerifyMigrationIncorrectPassword) to show the
// "re-enter your password" dialog instead of the fatal error dialog.
// 7002 = kGGLPErrorVerifyMigrationCodeNotExist, 7004 = incorrect password.
let (code, message) = if userdata::user::migration::transfer_code_exists(&migration_code) {
(7004, "Migration password is incorrect")
} else {
(7002, "Migration code does not exist")
};
object!{
result: "ERR",
messsage: "User Not Found"
result: "NG",
code: code,
message: message
}
} else {
let data_user = userdata::get_acc(&user["login_token"].to_string());

View File

@@ -124,6 +124,10 @@ async fn home(Login(key): Login) -> impl Responder {
}
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
user["home"]["beginner_mission_complete"] = 1.into();

View File

@@ -300,8 +300,10 @@ pub fn lp_modification(user: &mut JsonValue, change_amount: u64, remove: bool) -
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 {
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

View File

@@ -27,7 +27,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
async fn retire(Session { key, body }: Session) -> impl Responder {
live_retire(&key, &body);
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!{
"stamina": {},
@@ -81,7 +83,7 @@ fn random_number(lowest: usize, highest: usize) -> usize {
}
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 friends = userdata::get_acc_friends(&key);
let user = userdata::get_acc(&key);
@@ -136,7 +138,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
}).unwrap();
} else {
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;
if guest_list.len() < expected {
@@ -147,7 +149,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder {
}
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() {
continue;
}
@@ -222,12 +224,15 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) {
let mut expired = array![];
let curr_time = global::timestamp();
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();
}
if live["expire_date_time"].as_u64().unwrap() < curr_time {
if stale {
// 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() {
@@ -243,34 +248,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 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);
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);
if server_data["last_live_started"].is_null() {
return None;
}
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(rv)
Some(server_data["last_live_started"][index].clone())
}
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);
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);
}
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);
if server_data["last_live_started"].is_null() {
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();
// The user has 1 hour to complete a live
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into();
@@ -311,24 +376,32 @@ fn get_clear_count(id: i64, user: &JsonValue) -> i64 {
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 {
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!{
"master_live_id": data["master_live_id"].as_i64().unwrap(),
"level": data["level"].as_i64().unwrap(),
"master_live_id": data["master_live_id"].as_i64().unwrap_or(0),
"level": data["level"].as_i64().unwrap_or(0),
"clear_count": 1,
"high_score": data["live_score"]["score"].as_i64().unwrap(),
"max_combo": data["live_score"]["max_combo"].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_or(0),
"auto_enable": 1, //whats this?
"updated_time": global::timestamp()
};
let mut has = false;
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;
if add {
rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into();
@@ -348,7 +421,7 @@ pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> Js
}
current["updated_time"] = rv["updated_time"].clone();
rv["level"] = current["level"].clone();
if data["level"].as_i32().unwrap() != 0 {
if data["level"].as_i32().unwrap_or(0) != 0 {
break;
}
}
@@ -514,7 +587,9 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
Some(x) => x,
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,
None => continue
};
@@ -584,22 +659,46 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
}
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 user = userdata::get_acc(&key);
let mut user_missions = userdata::get_acc_missions(&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 first_clear = !skipped
&& 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 {
items::use_item(&object!{
value: 21000001,
amount: 1,
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!{
master_live_id: body["master_live_id"].clone(),
level: 0,
@@ -607,9 +706,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
score: 1,
max_combo: 1
}
}, false)
}, false, update_score_board)
} else {
update_live_data(&mut user, &body, true)
update_live_data(&mut user, &body, true, update_score_board)
};
//1273009, 1273010, 1273011, 1273012
@@ -624,21 +723,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;
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());
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user);
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(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 {
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());
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user);
live_completed(live_id, level as i32, false, score, board_uid);
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_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 items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() {
@@ -663,13 +768,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() {
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();
}
}
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()
});
@@ -677,7 +782,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);
items::lp_modification(&mut user, lp_used as u64, true);
if consume_lp {
items::lp_modification(&mut user, lp_used as u64, true);
}
items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions);
@@ -759,7 +866,7 @@ mod tests {
master_live_id: live_id,
level: level,
live_score: { score: score, max_combo: combo }
}, true);
}, true, true);
userdata::save_acc(token, user);
}
@@ -767,6 +874,118 @@ mod tests {
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]
fn clear_count_persists_with_custom_songs_disabled() {
let _lock = crate::runtime::lock_test_data_path();

View File

@@ -2,7 +2,8 @@ use jzon::{array, object, JsonValue};
use actix_web::{web, HttpRequest, Responder};
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) {
cfg.service(
@@ -106,6 +107,85 @@ fn get_random_cards(id: i64, mut count: usize) -> JsonValue {
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 {
(global::timestamp() as i64 + 32400) / 86400
}
@@ -209,18 +289,46 @@ 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) {
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 lottery = &databases::LOTTERY[lottery_id.to_string()];
let lottery_type = lottery["category"].as_i32().unwrap();
let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0);
let (price_id, rarity_id) = if is_stepup(lottery_id) && price_number == 1 {
let step = stepup_step(lottery_id, get_draw_count(&user, lottery_id, 1));
(step["masterLotteryPriceId"].as_i64().unwrap(), step["masterLotteryRarityId"].as_i64().unwrap())
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 {
(lottery["masterLotteryPriceId"].as_i64().unwrap_or(lottery_id), lottery["masterLotteryRarityId"].as_i64().unwrap_or(lottery_id))
let lottery = &databases::LOTTERY[lottery_id.to_string()];
let lottery_type = lottery["category"].as_i32().unwrap();
let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0);
let (price_id, rarity_id) = if is_stepup(lottery_id) && price_number == 1 {
let step = stepup_step(lottery_id, get_draw_count(&user, lottery_id, 1));
(step["masterLotteryPriceId"].as_i64().unwrap(), step["masterLotteryRarityId"].as_i64().unwrap())
} else {
(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 count = price["count"].as_usize().unwrap();
(price, get_random_cards(rarity_id, count), lottery_type, exchange_id)
};
let price = databases::PRICE[price_id.to_string()][price_number.to_string()].clone();
items::use_item(&object!{
value: price["masterItemId"].clone(),
@@ -228,10 +336,6 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
consumeType: price["consumeType"].clone()
}, 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 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_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!{
"lottery_item_list": lottery_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,
"item_list": user["item_list"].clone()
},
@@ -317,6 +431,64 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
#[cfg(test)]
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]
fn custom_banner_draw() {
for lid in [6110001i64, 6110002, 6110003, 6110004] {

797
src/router/multi_live.rs Normal file
View 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)
}
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);
}
}

View 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
);
}
}

File diff suppressed because it is too large Load Diff

367
src/router/multi_live/ws.rs Normal file
View 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));
}
}

View File

@@ -1,4 +1,4 @@
use jzon::{object, array};
use jzon::object;
use actix_web::{web, Responder};
use crate::router::Api;
@@ -15,5 +15,8 @@ async fn reward() -> 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
View 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");
}
}

View File

@@ -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?
}
// 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!{
"asset_hash": asset_hash,
"token": hex::encode("Hello") //what is this?

View File

@@ -1,6 +1,9 @@
use jzon::{array, object, JsonValue};
use lazy_static::lazy_static;
use std::collections::HashMap;
use crate::router::userdata;
use crate::router::global;
use crate::router::{card, custom_card, databases};
fn get_clear_count(user: &JsonValue, level: i32) -> i64 {
let mut rv = 0;
@@ -67,34 +70,66 @@ pub enum UserView {
const DEFAULT_CARD: i64 = 10010001;
fn proxy_card_id(id: i64) -> i64 {
let prefix = id / 10000;
if prefix < 10000 {
return id;
}
let rv = if prefix < 14000 {
(prefix - 9000) * 10000 + 1
} else {
DEFAULT_CARD
lazy_static! {
// Each character's lowest official card id: the stand-in shown to viewers
// who can't resolve a custom card of that character
static ref OFFICIAL_CARD_BY_CHARACTER: HashMap<i64, i64> = {
let mut rv: HashMap<i64, i64> = HashMap::new();
for entry in databases::CARD_LIST.entries() {
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;
}
}
rv
};
// Not every prefix has a real character behind it
if crate::router::databases::CARD_LIST[rv.to_string()].is_empty() {
return DEFAULT_CARD;
}
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
}
pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) {
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
let id = user["user"][key].as_i64().unwrap_or(0);
if crate::router::card::is_custom(id) {
if !custom_card::viewer_can_resolve(id, protocol) {
user["user"][key] = proxy_card_id(id).into();
}
}
// 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"] {
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]["master_card_id"] = proxy_card_id(id).into();
}
@@ -102,7 +137,8 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
if !user["main_deck_detail"].is_empty() {
let mut used = array![];
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
// same card twice
if card == 0 || used.contains(card) {
@@ -116,7 +152,7 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
let mut ids = array![];
for card in user["main_deck_detail"]["card_list"].members() {
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) {
continue;
}
@@ -132,7 +168,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);
if !user["error"].is_empty() {
return object!{};
@@ -182,9 +218,124 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool
}
}
if !custom_cards {
proxy_user_cards(&mut rv);
}
proxy_user_cards(&mut rv, protocol);
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());
}
}
// 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);
}
}

View File

@@ -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))
}
@@ -176,9 +181,10 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
pub async fn announcement(Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc_home(&key);
user["home"]["new_announcement_flag"] = (0).into();
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
userdata::save_acc_home(&key, user);
Api(Some(object!{
@@ -243,12 +249,12 @@ async fn migration(Body(body): Body) -> impl Responder {
async fn detail(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
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![];
for data in body["user_ids"].members() {
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();
}
Api(Some(object!{

View File

@@ -9,6 +9,7 @@ use crate::router::global;
use crate::router::items;
use crate::router::card;
use crate::database::custom_song;
use crate::database::custom_card;
use crate::sql::SQLite;
use crate::include_file;
@@ -245,6 +246,13 @@ fn get_uid(token: &str) -> i64 {
data.parse::<i64>().unwrap_or(0)
}
// The account a login token belongs to, 0 when the token is unknown. The HTTP layer
// never needs this (it keys everything off the token itself), but the multi-live relay
// authenticates a {userId, token} pair and has to check the two agree.
pub fn uid_from_login_token(token: &str) -> i64 {
get_uid(token)
}
// Needed by gree
pub fn get_login_token(uid: i64) -> String {
let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid));
@@ -332,10 +340,66 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
true
}
// Deleted custom cards leave stale card_list rows behind - and a card_list id
// the client can't resolve aborts its whole login. Wiped lazily when the
// userdata is pulled, mirroring remove_deleted_custom_songs: only the runtime
// band is a candidate (official/imported ids never are), ids are never
// reused, so the wipe is final. A card that still exists but is unpublished
// is NOT wiped - existence is what's checked, and the catalog keeps serving
// owned ids (custom_card::owned_runtime_ids) so holders still resolve them
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 {
let mut user = get_data(auth_key, "userdata");
cleanup_account(&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) {
changed = true;
@@ -418,6 +482,47 @@ pub fn save_acc_eventlogin(auth_key: &str, data: JsonValue) {
pub fn save_server_data(auth_key: &str, data: JsonValue) {
save_data(auth_key, "server_data", data);
}
// Read-modify-write of one account's server_data as ONE atomic step.
//
// get_server_data + save_server_data open a connection each, so two requests for the same
// account both read the pre-state and the later write wins - which for a record that is
// meant to be spent exactly once (the started-live record /multi_live/end awards off) means
// both ends see it and both award. Everything here happens inside a single BEGIN IMMEDIATE
// transaction instead, so concurrent callers serialise and the second one observes what the
// first one wrote.
//
// `f` sees the parsed server_data and returns whatever the caller needs out of it; the
// (possibly mutated) value is written back before the transaction commits. get_key runs
// BEFORE the transaction because it can create the account, which writes on its own
// connection and would otherwise deadlock against our write lock.
//
// A database error yields T::default() rather than a panic: the callers all have a
// "nothing to spend" branch, which is the right answer when the record could not be read.
pub fn modify_server_data<T: Default>(auth_key: &str, f: impl FnOnce(&mut JsonValue) -> T) -> T {
let key = get_key(auth_key);
let rv = DATABASE.lock_and_transact(|conn| {
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) {
save_data(auth_key, "chats", data);
}
@@ -777,4 +882,57 @@ mod tests {
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));
}
// 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);
}
}

View File

@@ -1,13 +1,29 @@
{
"point_ranking": {
"rank": 0,
"point": 0
},
"score_ranking": [],
"member_ranking": [],
"lottery_box": [],
"score_ranking": {
"all_rank": 0,
"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": [],
"policy_agreement": 0,
"incentive_lottery": 0,
"is_disconnected": 0,
"help_count": 0,
"penalty_remaining_time": 0,
"star_event": {
"star_level": 0,
"last_event_star_level": 0,

View File

@@ -38,6 +38,12 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
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 {
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)) {

View File

@@ -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 {
HttpResponse::Ok().body("sif2 is back!")
use crate::router::{global, userdata, webui};
use crate::database::{announcements, permissions};
use crate::database::announcements::Banner;
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}&amp;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}&amp;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);
}
}

View File

@@ -4,12 +4,13 @@ use actix_web::{
http::header::HeaderValue,
http::header::ContentType
};
use jzon::{JsonValue, object};
use jzon::{array, JsonValue, object};
use lazy_static::lazy_static;
use include_dir::{include_dir, Dir};
use std::fs;
use crate::include_file;
use crate::database::permissions;
use crate::router::{userdata, items};
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())
}
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 {
let resp = object!{
result: "ERR",
@@ -221,6 +228,8 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
data: {
account_import: get_config()["import"].as_bool().unwrap(),
custom_songs: !crate::router::custom_song::disabled(),
custom_cards: !crate::router::custom_card::disabled(),
custom_3dmv: !crate::router::custom_3dmv::disabled(),
links: {
global: args.global_android,
japan: args.japan_android,
@@ -358,6 +367,196 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
.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)
}
};
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))
}
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req);
if token.is_none() {
@@ -397,3 +596,37 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.insert_header(ContentType::json())
.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());
}
}

View File

@@ -7,6 +7,7 @@ use std::fs;
lazy_static! {
static ref RUNNING: RwLock<bool> = RwLock::new(false);
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_WARNED: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
static ref EASTER: RwLock<bool> = RwLock::new(false);
@@ -23,6 +24,8 @@ pub struct HostConfig {
pub jp_android_asset_hash: String,
pub en_android_asset_hash: String,
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
@@ -31,6 +34,26 @@ pub fn set_enable_custom_songs(enabled: bool) {
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) {
let mut w = RUNNING.write().unwrap();
*w = running;
@@ -144,11 +167,17 @@ pub fn overlay_args(args: &mut crate::options::Args) {
}
overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash);
// Overlay only ever enables the feature; a command-line --enable-custom-songs
// is never overridden back to off
// Overlay only ever enables the features; a command-line --enable-custom-*
// flag is never overridden back to off
if cfg.enable_custom_songs {
args.enable_custom_songs = true;
}
if cfg.enable_custom_cards {
args.enable_custom_cards = true;
}
if cfg.enable_custom_3dmv {
args.enable_custom_3dmv = true;
}
}
// 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, ()> {
let guard = crate::lock_onto_mutex!(TEST_LOCK);
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_cards(true);
set_enable_custom_3dmv(true);
guard
}

View File

@@ -59,4 +59,28 @@ impl SQLite {
}
Ok(rv)
}
// Runs a read-modify-write as one unit. Additive on purpose — the other helpers open
// a fresh connection per statement, so a caller that SELECTs then INSERTs races any
// concurrent caller doing the same and the loser hits a constraint violation (which
// lock_and_exec would unwrap into a worker panic).
//
// BEGIN IMMEDIATE takes the write lock up front rather than at first write, so two
// callers serialise instead of both reading the pre-state; busy_timeout makes the
// loser wait for the winner rather than fail instantly (SQLite::new sets that on its
// own short-lived setup connection, not on the per-call ones).
//
// Errors are returned, never unwrapped: statistics writes must not take down a
// request.
pub fn lock_and_transact<T>(
&self,
f: impl FnOnce(&Connection) -> Result<T, rusqlite::Error>
) -> Result<T, rusqlite::Error> {
let mut conn = Connection::open(&self.path)?;
conn.busy_timeout(std::time::Duration::from_secs(10))?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let rv = f(&tx)?;
tx.commit()?;
Ok(rv)
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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;
}

2
webui

Submodule webui updated: 896ef1ccda...c17a5b00dc