Compare commits

...

3 Commits

Author SHA1 Message Date
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
28 changed files with 4198 additions and 80 deletions

View File

@@ -14,6 +14,8 @@ 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)
#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,7 @@ 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)
add_opt() {
local value="$1" flag="$2"
@@ -30,6 +31,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,4 @@
pub mod gree;
pub mod custom_song;
pub mod custom_card;
pub mod permissions;

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

353
src/database/permissions.rs Normal file
View File

@@ -0,0 +1,353 @@
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);
}
// Scopes are flat dotted strings and imply everything below them: holding
// "card" grants "card.upload", and "*" grants everything. That hierarchy is
// the only notion of "level" - there is no rank integer, so a new capability
// is one line here and never a renumbering of anything else.
//
// Every call site must pass one of these consts, never a literal: an
// unrecognised scope string can only ever fail closed in has(), but grant()
// rejects it outright so a typo can't be persisted either.
pub const ALL: &str = "*";
pub const CARD: &str = "card";
// Create custom cards/characters, and edit or delete your OWN uploads
pub const CARD_UPLOAD: &str = "card.upload";
// Publish/unpublish and mark obtainable, on your OWN uploads
pub const CARD_PUBLISH: &str = "card.publish";
// Moderation: edit, delete, publish or unpublish ANYBODY's cards
pub const CARD_EDIT: &str = "card.edit";
pub const PERMISSION: &str = "permission";
pub const PERMISSION_GRANT: &str = "permission.grant";
pub const PERMISSION_REVOKE: &str = "permission.revoke";
// The whole grantable vocabulary, subtree roots included. Anything not in here
// cannot be written to the table
pub const SCOPES: &[&str] = &[
ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE
];
// Grants live in their own database rather than in userdata.db so that an
// account purge can never take administrative state with it
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS grants (
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();
}
// The owners are a process-level flag (--owner) rather than table rows: they
// are the bootstrap grantors, so they have to work on a fresh install with an
// empty (or hand-deleted) permissions.db, and they must not be revocable
// through the webui
fn is_owner(user_id: i64) -> bool {
user_id > 0 && crate::runtime::get_owners().contains(&user_id)
}
// Every scope that would satisfy a request for `scope`: "*", each dotted
// ancestor, and the scope itself. Matching is on whole dot-separated segments,
// so "car" never satisfies "card.upload"
fn implied_by(scope: &str) -> Vec<String> {
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 held_scopes(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 = held_scopes(user_id);
implied_by(scope).iter().any(|candidate| held.contains(candidate))
}
// Everything this user holds, for the webui to hide what it can't use. An
// owner's implicit "*" is reported here even though it has no row
pub fn scopes_for(user_id: i64) -> JsonValue {
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 held_scopes(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
}
// The only way a row is ever written. Two conditions, both required:
//
// 1. the grantor holds permission.grant, and
// 2. the grantor holds the scope being granted
//
// (2) is what makes escalation impossible. has() only ever implies downwards,
// so holding a leaf never satisfies its parent - a user with card.upload can
// hand out card.upload and nothing else, and can no more grant themselves
// "card" (or "*") than they can grant it to anybody else. Both checks read the
// live table, so a revoked grantor loses the ability on their next request
pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
}
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(())
}
// Revoking needs the same "you must hold it yourself" rule as granting, so a
// junior admin can't strip a senior one. An owner has no rows to delete, but
// the check is explicit so it stays true if that ever changes
pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
}
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(())
}
#[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!(scopes_for(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!(held_scopes(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!(scopes_for(118).len(), 1);
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL));
assert!(held_scopes(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] {
assert!(SCOPES.contains(&scope), "scope {}", scope);
}
}
}

View File

@@ -27,6 +27,8 @@ 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();

View File

@@ -41,6 +41,12 @@ 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, 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

@@ -21,6 +21,7 @@ pub mod web;
pub mod card;
pub mod shop;
pub mod custom_song;
pub mod custom_card;
pub mod webui;
pub mod clear_rate;
pub mod exchange;
@@ -217,6 +218,8 @@ 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 {
@@ -231,6 +234,11 @@ 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/myScopes" => webui::my_scopes(req),
_ => api_req(req, body).await
}
}
@@ -253,6 +261,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(card::routes)
.configure(chat::routes)
.configure(custom_song::routes)
.configure(custom_card::routes)
.configure(debug::routes)
.configure(event::routes)
.configure(exchange::routes)
@@ -279,4 +288,5 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(gree::routes)
);
cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::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

@@ -255,7 +255,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 +266,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()

2016
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,4 +1,6 @@
mod audio;
// audio is shared: custom_card voicelines transcode through the same
// in-process symphonia + vorbis machinery
pub mod audio;
mod chart;
mod package;
@@ -770,7 +772,6 @@ async fn upload(req: HttpRequest, payload: Multipart) -> HttpResponse {
}
}
}
println!("UPLOAD5");
match create_song(uid, &fields) {
Ok(music_id) => send_json(object!{
result: "OK",
@@ -1399,7 +1400,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();

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,10 +43,22 @@ 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
@@ -183,16 +195,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() && 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 +250,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();
}
@@ -324,6 +353,22 @@ mod tests {
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
}
// 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 +382,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]

View File

@@ -223,7 +223,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 +232,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;
}

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

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

@@ -81,7 +81,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 +136,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 +147,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;
}
@@ -514,7 +514,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
};

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] {

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,12 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
}
}
// Runtime custom cards are unresolvable below protocol 3. start.rs blocks
// flagged accounts on old clients, so this is belt-and-braces
if !crate::router::custom_card::client_supports(&req) {
crate::router::custom_card::strip_unsupported(&mut user);
}
global::api(&req, Some(user))
}
@@ -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;
@@ -332,10 +333,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;
@@ -777,4 +834,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

@@ -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,7 @@ 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(),
links: {
global: args.global_android,
japan: args.japan_android,
@@ -358,6 +366,192 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(ITEM.clone()))
}
lazy_static! {
// The selectable character list for the custom-card form: every official
// and SIF1-imported character in the baked csv, by name. The import band
// starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014
static ref CHARACTER_CHOICES: JsonValue = {
let mut rv = jzon::array![];
for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
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
};
// The skill_center table with its display strings, for picking a center
// skill by name instead of by raw id
static ref SKILL_CENTER_CHOICES: JsonValue = {
let mut en_rows = object!{};
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
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
};
}
// The characters a card upload may reference, for the webui's searchable
// picker: the baked official + imported list, plus the custom characters
// this session may build on (their own and the publicly visible ones)
pub fn list_characters(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
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))
}
// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array
// lengths) so the form enforces them before submitting
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
if session_uid(&req).is_none() {
return error("Not logged in");
}
let resp = object!{
result: "OK",
data: crate::router::custom_card::upload_limits()
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
// The requesting user's own effective scopes, for webui nav gating. Any
// session may ask - it only ever reveals what the user themselves holds
pub fn 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::scopes_for(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_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|| permissions::has(uid, permissions::PERMISSION_REVOKE)
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
// The admin view: every grant plus the grantable vocabulary. Needs a
// permission.* scope - my_scopes is the anyone-can-ask endpoint
pub fn list_permissions(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
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::scopes_for(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 +591,32 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
#[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,7 @@ 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,
}
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -31,6 +33,22 @@ 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;
}
// 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 +162,14 @@ 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-songs
// / --enable-custom-cards 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;
}
}
// idk why an ai put tests here but they are here now. Yay tests????
@@ -167,7 +188,9 @@ 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);
guard
}

2
webui

Submodule webui updated: 896ef1ccda...28b1c65fc6