mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
2278 lines
105 KiB
Rust
2278 lines
105 KiB
Rust
mod art;
|
|
|
|
use jzon::{array, object, JsonValue};
|
|
use actix_web::{web, HttpRequest, HttpResponse, Responder, http::header::ContentType};
|
|
use actix_multipart::Multipart;
|
|
use futures_util::TryStreamExt;
|
|
use lazy_static::lazy_static;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fs;
|
|
use std::sync::Mutex;
|
|
|
|
use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
|
|
use crate::router::databases::csv::{table, Region};
|
|
use crate::router::custom_song::audio;
|
|
use crate::database::custom_card as database;
|
|
use crate::database::permissions;
|
|
use crate::runtime::get_data_path;
|
|
use crate::lock_onto_mutex;
|
|
|
|
// Runtime-uploaded cards and characters. Unlike the SIF1 import (id prefixes
|
|
// 10000-14999, baked into client masterdata at build time) these rows do not
|
|
// exist in any shipped table: the client fetches them from
|
|
// /api/custom_card/list at login (protocol version 3) and appends them to its
|
|
// Mst tables before the first payload carrying card_list arrives.
|
|
//
|
|
// Cards are owned by their uploader. Draft by default: a draft is served to
|
|
// its owner's catalog only. Publishing puts it in everyone's catalog, and
|
|
// "obtainable" additionally enters it into the client-synthesized custom
|
|
// gacha banner (lottery id 6900001), whose draw lottery.rs special-cases.
|
|
// Filtering is at the CATALOG level; the art GET is content-addressed and
|
|
// sessionless, like a CDN.
|
|
//
|
|
// Storage layout (under --path):
|
|
// custom_cards/{master_card_id}/{kind}_{variant}.png c_00.png ... sc_01.png
|
|
// custom_cards/characters/{master_character_id}/{kind}.png
|
|
// Metadata lives in custom_cards.db as one JSON blob per card / character, in
|
|
// the exact shape /api/custom_card/list serves.
|
|
|
|
// Level 1 = custom songs, 2 = resolves the baked SIF1-import band, 3 = fetches
|
|
// the runtime custom-card catalog
|
|
pub const PROTOCOL_VERSION: u32 = 3;
|
|
|
|
// The client-synthesized custom gacha banner. The 6M band is reserved for
|
|
// custom lotteries (the baked SIF1 banners are 6110001-6110004)
|
|
pub const CUSTOM_LOTTERY_ID: i64 = 6_900_001;
|
|
|
|
// Upload limits, enforced while the multipart field is still streaming (the
|
|
// 25MB PayloadConfig in lib.rs binds the String/Bytes extractors, not
|
|
// Multipart). A card upload carries 14 png files, so the per-request cap is
|
|
// the binding one
|
|
pub const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
|
|
pub const MAX_REQUEST_BYTES: usize = 64 * 1024 * 1024;
|
|
pub const MAX_CARDS_PER_USER: i64 = 500;
|
|
|
|
// Columns the uploader never supplies. master_release_label_id must be 1: a
|
|
// closed label filters the card out of the member-list filters and drops its
|
|
// evolve conditions
|
|
const MASTER_RELEASE_LABEL_ID: i64 = 1;
|
|
// card_get.csv category 1 = GACHA, matching how the card is actually obtained
|
|
const GET_CATEGORY_GACHA: i64 = 1;
|
|
|
|
// Every one of the 172 imported characters carries exactly these values
|
|
// (csv/character.csv), and category 6 (BAND_CATEGORY OTHER) is not cosmetic -
|
|
// SDCharacter substitutes the 99999 atlas for an OTHER-category character,
|
|
// which is what gives a new character a working SD chibi with no new asset.
|
|
// master_group_id 9000 is a real group id in character_group.csv - a
|
|
// nonexistent group id is the class of bug that KeyNotFound-crashed custom
|
|
// songs. These are NUMBERS on the wire, never enum names
|
|
const CHARACTER_CATEGORY_OTHER: i64 = 6;
|
|
const CHARACTER_CHARA_CATEGORY: i64 = 1;
|
|
const CHARACTER_GROUP_ID: i64 = 9000;
|
|
const CHARACTER_SCHOOL_GRADE: i64 = 0;
|
|
|
|
// Client enum ranges (crash ranges, not cosmetic: the member sorter indexes
|
|
// arrays sized to the enum with the raw value). Kept as named consts in ONE
|
|
// place; the client agent is re-verifying the exact ceilings
|
|
// 0 = NONE exists in the client enum but no shipped skill row uses it (a NONE
|
|
// live skill would do nothing), so uploads start at 1
|
|
const SKILL_EFFECT_TYPE_MIN: i64 = 1;
|
|
const SKILL_EFFECT_TYPE_MAX: i64 = 11;
|
|
const SKILL_TRIGGER_MIN: i64 = 1;
|
|
const SKILL_TRIGGER_MAX: i64 = 4;
|
|
const SKILL_SUB_TARGET_MAX: i64 = 1;
|
|
const SKILL_SCHOOL_GRADE_MAX: i64 = 3;
|
|
const CARD_TYPE_MIN: i64 = 1;
|
|
const CARD_TYPE_MAX: i64 = 4;
|
|
const CARD_RARITY_MIN: i64 = 1;
|
|
const CARD_RARITY_MAX: i64 = 3;
|
|
const RARITY_NAMES: &[&str] = &["R", "SR", "UR"];
|
|
// Indexed by effect_type (0 unused)
|
|
const EFFECT_NAMES: &[&str] = &[
|
|
"none", "smile p up", "pure p up", "cool p up", "score up",
|
|
"perfect window", "stamina recovery", "skill chance up",
|
|
"skill level boost", "param sync", "combo fever", "skill repeat"
|
|
];
|
|
|
|
struct ArtKind {
|
|
kind: &'static str,
|
|
width: u32,
|
|
height: u32
|
|
}
|
|
|
|
// Official target dimensions, verified against the shipped art. Every kind is
|
|
// DERIVED from the per-variant source artwork (art_00 / art_01) by the SIF1
|
|
// import pipeline's recipes (see art.rs); an explicitly supplied kind file
|
|
// overrides the derived one and is itself cover-cropped + resized to target,
|
|
// never rejected for dimensions. The stored/hashed bytes are always the
|
|
// processed PNG
|
|
const CARD_ART: &[ArtKind] = &[
|
|
ArtKind { kind: "c", width: 2048, height: 1260 },
|
|
ArtKind { kind: "h", width: 2048, height: 1260 },
|
|
ArtKind { kind: "t", width: 512, height: 315 },
|
|
ArtKind { kind: "p", width: 136, height: 508 },
|
|
ArtKind { kind: "r", width: 256, height: 256 },
|
|
ArtKind { kind: "m", width: 380, height: 380 },
|
|
ArtKind { kind: "sc", width: 1024, height: 512 }
|
|
];
|
|
|
|
// "00" = base, "01" = evolved. Both are required for every kind: the client
|
|
// picks evolve ? evolve_illust_id : illust_id with no rarity gate
|
|
const CARD_ART_VARIANTS: &[&str] = &["00", "01"];
|
|
|
|
const CHARACTER_ART: &[ArtKind] = &[
|
|
ArtKind { kind: "pr", width: 512, height: 615 },
|
|
ArtKind { kind: "icon", width: 230, height: 230 },
|
|
ArtKind { kind: "sign", width: 300, height: 330 },
|
|
ArtKind { kind: "character", width: 600, height: 920 }
|
|
];
|
|
|
|
// Optional voicelines per character. Multipart fields per line:
|
|
// voice_{kind}_{index} audio file (any format symphonia reads;
|
|
// transcoded to ogg-vorbis, stored
|
|
// content-addressed like the art)
|
|
// voice_{kind}_{index}_text caption, may be empty
|
|
// voice_{kind}_{index}_text_en English caption, may be empty
|
|
// voice_{kind}_{index}_delete flag: remove this line
|
|
// Absent slots keep their stored line, captions without a file update the
|
|
// stored line's captions, and surviving lines are renumbered contiguously
|
|
// per kind (1..n) after every edit
|
|
const VOICE_KINDS: &[&str] = &[
|
|
"live_start", "live_success", "live_failed", "result_bond",
|
|
"skill_smile", "skill_pure", "skill_cool"
|
|
];
|
|
const MAX_VOICE_VARIANTS: usize = 9;
|
|
const MAX_VOICE_BYTES: usize = 4 * 1024 * 1024;
|
|
const MAX_VOICE_SECONDS: f64 = 30.0;
|
|
|
|
type Fields = HashMap<String, Vec<u8>>;
|
|
|
|
// new_skill's level-array columns come out of the csv layer as either
|
|
// [number] (a single plain value) or ["a/b/c"] (the slash-packed string) -
|
|
// flatten both into the numeric values
|
|
fn shipped_values(cell: &JsonValue) -> Vec<i64> {
|
|
let mut rv = Vec::new();
|
|
let mut sources: Vec<String> = Vec::new();
|
|
for member in cell.members() {
|
|
if let Some(value) = member.as_i64() {
|
|
rv.push(value);
|
|
} else if let Some(s) = member.as_str() {
|
|
sources.push(s.to_string());
|
|
}
|
|
}
|
|
if !cell.is_array() {
|
|
if let Some(value) = cell.as_i64() {
|
|
rv.push(value);
|
|
} else if let Some(s) = cell.as_str() {
|
|
sources.push(s.to_string());
|
|
}
|
|
}
|
|
for source in sources {
|
|
for part in source.split('/') {
|
|
if let Ok(value) = part.trim().parse::<i64>() {
|
|
rv.push(value);
|
|
}
|
|
}
|
|
}
|
|
rv
|
|
}
|
|
|
|
lazy_static! {
|
|
// Id allocation and the insert must not race between two uploads
|
|
static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
static ref OFFICIAL_CHARACTER_IDS: HashSet<i64> = {
|
|
table(Region::Jp, "character").members().filter_map(|row| row["id"].as_i64()).collect()
|
|
};
|
|
|
|
static ref SKILL_CENTER_IDS: HashSet<i64> = {
|
|
table(Region::Jp, "skill_center").members().filter_map(|row| row["id"].as_i64()).collect()
|
|
};
|
|
|
|
// Real GroupMst ids, from the character->group mapping. skill
|
|
// target_group_id must be 0 or one of these
|
|
static ref GROUP_IDS: HashSet<i64> = {
|
|
table(Region::Jp, "character_group").members().filter_map(|row| row["groupId"].as_i64()).collect()
|
|
};
|
|
|
|
// rarity -> how many skill levels its curve has (3 / 5 / 9). The client
|
|
// indexes the level arrays in lockstep with the skill level, so this is
|
|
// the required length of every level-indexed array
|
|
static ref SKILL_LEVEL_COUNT: HashMap<i64, usize> = {
|
|
let mut per_curve: HashMap<i64, usize> = HashMap::new();
|
|
for row in table(Region::Jp, "card_skill_level").members() {
|
|
if let Some(id) = row["id"].as_i64() {
|
|
*per_curve.entry(id).or_insert(0) += 1;
|
|
}
|
|
}
|
|
let mut rv = HashMap::new();
|
|
for row in table(Region::Jp, "card_rarity").members() {
|
|
let (Some(rarity), Some(curve)) = (row["rarity"].as_i64(), row["masterCardSkillLevelId"].as_i64()) else { continue; };
|
|
rv.insert(rarity, *per_curve.get(&curve).unwrap_or(&0));
|
|
}
|
|
rv
|
|
};
|
|
|
|
// Skill MAGNITUDE envelopes derived from the shipped new_skill.csv, the
|
|
// same way STAT_CAPS derives from card.csv. Enums are bounded elsewhere;
|
|
// these bound the numbers (a 40-second buff and a 1e8 score-up both got
|
|
// uploaded before this existed). Official rows (id < 100M) define the
|
|
// range; effect types no official row uses (1-3, 7-11 - the SIF1-port
|
|
// additions) fall back to the imported band's engineered range, flagged
|
|
// so the error message says which. (min, max, "official"/"imported")
|
|
static ref SKILL_VALUE_RANGES: HashMap<i64, (i64, i64, &'static str)> = {
|
|
let mut official: HashMap<i64, (i64, i64)> = HashMap::new();
|
|
let mut imported: HashMap<i64, (i64, i64)> = HashMap::new();
|
|
for row in table(Region::Jp, "new_skill").members() {
|
|
let Some(id) = row["id"].as_i64() else { continue; };
|
|
let Some(effect) = row["effectType"].as_i64() else { continue; };
|
|
let into = if id < 100_000_000 { &mut official } else { &mut imported };
|
|
for value in shipped_values(&row["effectiveValues"]) {
|
|
let entry = into.entry(effect).or_insert((value, value));
|
|
entry.0 = entry.0.min(value);
|
|
entry.1 = entry.1.max(value);
|
|
}
|
|
}
|
|
let mut rv = HashMap::new();
|
|
for (effect, (min, max)) in imported {
|
|
rv.insert(effect, (min, max, "imported"));
|
|
}
|
|
for (effect, (min, max)) in official {
|
|
rv.insert(effect, (min, max, "official"));
|
|
}
|
|
rv
|
|
};
|
|
|
|
// trigger -> (min, max) trigger_value over the official rows (trigger 4 =
|
|
// SECOND counts in milliseconds, hence its larger numbers)
|
|
static ref SKILL_TRIGGER_RANGES: HashMap<i64, (i64, i64)> = {
|
|
let mut rv: HashMap<i64, (i64, i64)> = HashMap::new();
|
|
for row in table(Region::Jp, "new_skill").members() {
|
|
if row["id"].as_i64().unwrap_or(i64::MAX) >= 100_000_000 { continue; }
|
|
let Some(trigger) = row["trigger"].as_i64() else { continue; };
|
|
for value in shipped_values(&row["triggerValue"]) {
|
|
let entry = rv.entry(trigger).or_insert((value, value));
|
|
entry.0 = entry.0.min(value);
|
|
entry.1 = entry.1.max(value);
|
|
}
|
|
}
|
|
rv
|
|
};
|
|
|
|
// (probability, effective_milli_secs) global (min, max) over official rows
|
|
static ref SKILL_SCALAR_RANGES: ((i64, i64), (i64, i64)) = {
|
|
let mut prob = (i64::MAX, 0);
|
|
let mut ms = (i64::MAX, 0);
|
|
for row in table(Region::Jp, "new_skill").members() {
|
|
if row["id"].as_i64().unwrap_or(i64::MAX) >= 100_000_000 { continue; }
|
|
for value in shipped_values(&row["probability"]) {
|
|
prob.0 = prob.0.min(value);
|
|
prob.1 = prob.1.max(value);
|
|
}
|
|
for value in shipped_values(&row["effectiveMilliSecs"]) {
|
|
ms.0 = ms.0.min(value);
|
|
ms.1 = ms.1.max(value);
|
|
}
|
|
}
|
|
(prob, ms)
|
|
};
|
|
|
|
// rarity -> (hp, smile, cool, pure) ceilings: the maximum any official
|
|
// card of that rarity reaches. do_reinforce trusts the stored card
|
|
// completely, so an uploaded stat is permanent - cap it at upload
|
|
static ref STAT_CAPS: HashMap<i64, (i64, i64, i64, i64)> = {
|
|
let mut rv: HashMap<i64, (i64, i64, i64, i64)> = HashMap::new();
|
|
for row in databases::CARD_LIST.entries() {
|
|
let card = row.1;
|
|
let Some(id) = card["id"].as_i64() else { continue; };
|
|
// The imported band shares official stat scales; both are fine as
|
|
// a ceiling source, runtime cards are excluded by construction
|
|
if id >= database::FIRST_CARD_ID {
|
|
continue;
|
|
}
|
|
let Some(rarity) = card["rarity"].as_i64() else { continue; };
|
|
let entry = rv.entry(rarity).or_insert((0, 0, 0, 0));
|
|
entry.0 = entry.0.max(card["hp"].as_i64().unwrap_or(0));
|
|
entry.1 = entry.1.max(card["smile"].as_i64().unwrap_or(0));
|
|
entry.2 = entry.2.max(card["cool"].as_i64().unwrap_or(0));
|
|
entry.3 = entry.3.max(card["pure"].as_i64().unwrap_or(0));
|
|
}
|
|
rv
|
|
};
|
|
}
|
|
|
|
// Game endpoints (/api scope, standard envelope)
|
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
|
cfg.service(
|
|
web::scope("/custom_card")
|
|
.route("/list", web::post().to(list))
|
|
);
|
|
}
|
|
|
|
// Plain art GET for the game + session-authenticated management API for the
|
|
// webui. Mounted OUTSIDE /api so the game middlewares never wrap it
|
|
pub fn web_routes(cfg: &mut web::ServiceConfig) {
|
|
cfg.service(
|
|
web::scope("/custom_card")
|
|
.route("/data/{hash}/{file}", web::get().to(data))
|
|
.route("/voice/{hash}/{file}", web::get().to(voice))
|
|
.route("/create", web::post().to(create))
|
|
.route("/update", web::post().to(update))
|
|
.route("/publish", web::post().to(publish))
|
|
.route("/delete", web::post().to(delete))
|
|
.route("/mine", web::get().to(mine))
|
|
.route("/browse", web::get().to(browse))
|
|
.route("/character/create", web::post().to(character_create))
|
|
.route("/character/update", web::post().to(character_update))
|
|
.route("/character/delete", web::post().to(character_delete))
|
|
);
|
|
}
|
|
|
|
// The whole feature is opt-in (--enable-custom-cards) and additionally off in
|
|
// --hidden mode. When disabled every endpoint 404s / errors as if it never
|
|
// existed, nothing touches custom_cards.db (so no table setup runs), and no
|
|
// runtime card ever resolves
|
|
pub fn disabled() -> bool {
|
|
let args = crate::get_args();
|
|
args.hidden || !args.enable_custom_cards
|
|
}
|
|
|
|
// The runtime-uploaded band. card::is_custom covers 100M+ (baked import
|
|
// included); this is the narrower "not in any shipped masterdata" test
|
|
pub fn is_custom_runtime(master_card_id: i64) -> bool {
|
|
(database::FIRST_CARD_ID..=database::LAST_CARD_ID).contains(&master_card_id)
|
|
}
|
|
|
|
pub fn client_supports(req: &HttpRequest) -> bool {
|
|
global::client_protocol_version(req) >= PROTOCOL_VERSION
|
|
}
|
|
|
|
pub fn account_supports(auth_key: &str) -> bool {
|
|
userdata::get_protocol_version(auth_key) >= PROTOCOL_VERSION
|
|
}
|
|
|
|
// Whether a viewer on this protocol version can turn `master_card_id` into a
|
|
// card row. "Supports the protocol" is not "has this card in its catalog": a
|
|
// draft (or a card published after the viewer logged in) is unresolvable by a
|
|
// fully modern client, and the client throws on an unknown id rather than
|
|
// degrading. The protocol test comes first so an older viewer never touches
|
|
// custom_cards.db
|
|
pub fn viewer_can_resolve(master_card_id: i64, protocol: u32) -> bool {
|
|
if !crate::router::card::is_custom(master_card_id) {
|
|
return true;
|
|
}
|
|
if !is_custom_runtime(master_card_id) {
|
|
return protocol >= crate::router::card::PROTOCOL_VERSION;
|
|
}
|
|
if disabled() || protocol < PROTOCOL_VERSION {
|
|
return false;
|
|
}
|
|
database::is_published(master_card_id)
|
|
}
|
|
|
|
// The character a runtime card belongs to, for guest::proxy_card_id
|
|
pub fn character_of(master_card_id: i64) -> Option<i64> {
|
|
if disabled() {
|
|
return None;
|
|
}
|
|
database::character_of(master_card_id)
|
|
}
|
|
|
|
// The card row for any master_card_id, in the csv CARD_LIST shape. Official
|
|
// and imported cards come straight from the baked table; runtime cards are
|
|
// synthesized from the db blob so the CARD_LIST consumers (reinforce, evolve,
|
|
// rarity, bond) keep working on them. Null when the id doesn't exist anywhere
|
|
pub fn card_info(master_card_id: i64) -> JsonValue {
|
|
let official = &databases::CARD_LIST[master_card_id.to_string()];
|
|
if !official.is_empty() {
|
|
return official.clone();
|
|
}
|
|
if !is_custom_runtime(master_card_id) || disabled() {
|
|
return JsonValue::Null;
|
|
}
|
|
let Some(card) = database::get_card(master_card_id) else {
|
|
return JsonValue::Null;
|
|
};
|
|
object!{
|
|
"id": master_card_id,
|
|
"masterCharacterId": card["master_character_id"].clone(),
|
|
"type": card["type"].clone(),
|
|
"rarity": card["rarity"].clone(),
|
|
"masterCardLevelId": card["master_card_level_id"].clone()
|
|
}
|
|
}
|
|
|
|
// Runtime-band cards are unresolvable by a client that can't fetch the
|
|
// catalog, and one unknown id in card_list aborts the whole login. start.rs
|
|
// blocks flagged accounts on old clients, so this is belt-and-braces for the
|
|
// shared /api/user response
|
|
pub fn strip_unsupported(user: &mut JsonValue) {
|
|
let mut dropped = array![];
|
|
let mut card_list = array![];
|
|
for card in user["card_list"].members() {
|
|
let id = card["master_card_id"].as_i64().unwrap_or(0);
|
|
if is_custom_runtime(id) {
|
|
dropped.push(id).unwrap();
|
|
continue;
|
|
}
|
|
card_list.push(card.clone()).unwrap();
|
|
}
|
|
if dropped.is_empty() {
|
|
return;
|
|
}
|
|
user["card_list"] = card_list;
|
|
for deck in user["deck_list"].members_mut() {
|
|
for id in deck["main_card_ids"].members_mut() {
|
|
if dropped.contains(id.as_i64().unwrap_or(0)) {
|
|
*id = (0).into();
|
|
}
|
|
}
|
|
}
|
|
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
|
|
if dropped.contains(user["user"][key].as_i64().unwrap_or(0)) {
|
|
user["user"][key] = (0).into();
|
|
}
|
|
}
|
|
}
|
|
|
|
// The concrete upload bounds, served to the webui so the form can enforce
|
|
// them client-side (sliders/radios) and reject out-of-range values instantly
|
|
pub fn upload_limits() -> JsonValue {
|
|
let mut stat_caps = object!{};
|
|
let mut skill_levels = object!{};
|
|
for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX {
|
|
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
|
|
stat_caps[rarity.to_string()] = object!{
|
|
"hp": caps.0,
|
|
"smile": caps.1,
|
|
"cool": caps.2,
|
|
"pure": caps.3
|
|
};
|
|
skill_levels[rarity.to_string()] = (*SKILL_LEVEL_COUNT.get(&rarity).unwrap_or(&0)).into();
|
|
}
|
|
// The real groups a skill may target, with display names for the webui's
|
|
// dropdown (nobody knows the raw group ids)
|
|
let mut rows: Vec<(i64, JsonValue)> = table(Region::Jp, "group").members()
|
|
.filter_map(|row| Some((row["id"].as_i64()?, row.clone())))
|
|
.collect();
|
|
rows.sort_by_key(|(id, _)| *id);
|
|
let mut groups = array![];
|
|
for (id, row) in rows {
|
|
groups.push(object!{
|
|
"id": id,
|
|
"name": row["name"].clone(),
|
|
"name_en": row["nameEn"].clone()
|
|
}).unwrap();
|
|
}
|
|
// The skill magnitude envelopes, so the form's number inputs carry real
|
|
// min/max and reject out-of-range (and e-notation) before submitting
|
|
let mut effect_value_ranges = object!{};
|
|
for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() {
|
|
effect_value_ranges[effect.to_string()] = object!{
|
|
"min": *min,
|
|
"max": *max,
|
|
"source": *source
|
|
};
|
|
}
|
|
let mut trigger_value_ranges = object!{};
|
|
for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() {
|
|
trigger_value_ranges[trigger.to_string()] = object!{
|
|
"min": *min,
|
|
"max": *max
|
|
};
|
|
}
|
|
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
|
object!{
|
|
"stat_caps": stat_caps,
|
|
"skill_levels": skill_levels,
|
|
"groups": groups,
|
|
"trigger_min": SKILL_TRIGGER_MIN,
|
|
"trigger_max": SKILL_TRIGGER_MAX,
|
|
"effect_type_min": SKILL_EFFECT_TYPE_MIN,
|
|
"effect_type_max": SKILL_EFFECT_TYPE_MAX,
|
|
"sub_target_max": SKILL_SUB_TARGET_MAX,
|
|
"school_grade_max": SKILL_SCHOOL_GRADE_MAX,
|
|
"skill_ranges": {
|
|
"effect_values": effect_value_ranges,
|
|
"trigger_values": trigger_value_ranges,
|
|
"probability": { "min": prob_min, "max": prob_max },
|
|
"milli_secs": { "min": ms_min, "max": ms_max }
|
|
},
|
|
"min_source_dim": art::MIN_SOURCE_DIM
|
|
}
|
|
}
|
|
|
|
// The runtime card ids in a game account's card_list
|
|
pub fn owned_runtime_ids(user: &JsonValue) -> Vec<i64> {
|
|
user["card_list"].members()
|
|
.filter_map(|card| card["master_card_id"].as_i64())
|
|
.filter(|id| is_custom_runtime(*id))
|
|
.collect()
|
|
}
|
|
|
|
// The catalog is filtered per requesting user: everyone gets the published
|
|
// cards, the owner additionally gets their drafts, and a game account that
|
|
// already owns a card keeps resolving it even if it was since unpublished
|
|
async fn list(Login(key): Login) -> impl Responder {
|
|
if disabled() {
|
|
// As if the endpoint doesn't exist - the client treats this as feature-off
|
|
return Api(None);
|
|
}
|
|
let user = userdata::get_acc(&key);
|
|
let uid = user["user"]["id"].as_i64().unwrap();
|
|
let cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
|
|
let characters = database::get_characters_for_cards(uid, &cards);
|
|
Api(Some(object!{
|
|
"revision": database::get_revision(),
|
|
"characters": characters,
|
|
"cards": cards
|
|
}))
|
|
}
|
|
|
|
fn card_dir(master_card_id: i64) -> String {
|
|
get_data_path(&format!("custom_cards/{}", master_card_id))
|
|
}
|
|
|
|
fn character_dir(master_character_id: i64) -> String {
|
|
get_data_path(&format!("custom_cards/characters/{}", master_character_id))
|
|
}
|
|
|
|
fn asset_path(relative: &str) -> String {
|
|
get_data_path(&format!("custom_cards/{}", relative))
|
|
}
|
|
|
|
// Content-addressed art fetch: '{server}/custom_card/data/{md5}/{md5}.png'.
|
|
// The game builds the URL from the md5 it read in the catalog and caches by
|
|
// it, so a stale md5 simply 404s and the client re-downloads under the new
|
|
// one. Visible to all like the custom-song data route (CDN semantics) - only
|
|
// the feature flag gates it
|
|
async fn data(req: HttpRequest) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let hash = req.match_info().get("hash").unwrap_or("").to_string();
|
|
let file = req.match_info().get("file").unwrap_or("").to_string();
|
|
if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || !file.starts_with(&format!("{}.", hash)) {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(relative) = database::find_asset_by_md5(&hash) else {
|
|
return HttpResponse::NotFound().finish();
|
|
};
|
|
match fs::read(asset_path(&relative)) {
|
|
Ok(body) => {
|
|
HttpResponse::Ok()
|
|
.insert_header(ContentType::png())
|
|
.insert_header(("content-length", body.len()))
|
|
.body(body)
|
|
},
|
|
Err(_) => HttpResponse::NotFound().finish()
|
|
}
|
|
}
|
|
|
|
// Voiceline oggs, same sessionless content-addressed semantics as the art
|
|
// data route: '{server}/custom_card/voice/{md5}/{md5}.ogg'
|
|
async fn voice(req: HttpRequest) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let hash = req.match_info().get("hash").unwrap_or("").to_string();
|
|
let file = req.match_info().get("file").unwrap_or("").to_string();
|
|
if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || file != format!("{}.ogg", hash) {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(relative) = database::find_voice_by_md5(&hash) else {
|
|
return HttpResponse::NotFound().finish();
|
|
};
|
|
match fs::read(asset_path(&relative)) {
|
|
Ok(body) => {
|
|
HttpResponse::Ok()
|
|
.insert_header(("content-type", "audio/ogg"))
|
|
.insert_header(("content-length", body.len()))
|
|
.body(body)
|
|
},
|
|
Err(_) => HttpResponse::NotFound().finish()
|
|
}
|
|
}
|
|
|
|
fn get_session_uid(req: &HttpRequest) -> Option<i64> {
|
|
let token = webui::get_login_token(req)?;
|
|
let login_token = userdata::webui_login_token(&token)?;
|
|
userdata::get_acc(&login_token)["user"]["id"].as_i64()
|
|
}
|
|
|
|
fn send_json(resp: JsonValue) -> HttpResponse {
|
|
HttpResponse::Ok()
|
|
.insert_header(ContentType::json())
|
|
.body(jzon::stringify(resp))
|
|
}
|
|
|
|
// The per-file cap is enforced while the field is still streaming, BEFORE any
|
|
// byte reaches the png decoder, so a decompression bomb is never decoded. The
|
|
// per-request cap is checked over the running total
|
|
async fn read_multipart(mut payload: Multipart) -> Result<Fields, String> {
|
|
let mut fields = Fields::new();
|
|
let mut total = 0usize;
|
|
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
|
|
let name = field.name().unwrap_or("").to_string();
|
|
let mut data = Vec::new();
|
|
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
|
|
total += chunk.len();
|
|
if total > MAX_REQUEST_BYTES {
|
|
return Err(format!("Upload exceeds the {} MB per-request limit", MAX_REQUEST_BYTES / (1024 * 1024)));
|
|
}
|
|
data.extend_from_slice(&chunk);
|
|
if data.len() > MAX_FILE_BYTES {
|
|
return Err(format!("'{}' exceeds the {} MB per-file limit", name, MAX_FILE_BYTES / (1024 * 1024)));
|
|
}
|
|
}
|
|
fields.insert(name, data);
|
|
}
|
|
Ok(fields)
|
|
}
|
|
|
|
fn field_str(fields: &Fields, key: &str) -> String {
|
|
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
|
|
}
|
|
|
|
// Checkbox-style flag: "1", "true" or "on"
|
|
fn field_flag(fields: &Fields, key: &str) -> bool {
|
|
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
|
|
}
|
|
|
|
fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec<u8>> {
|
|
fields.get(key).filter(|v| !v.is_empty())
|
|
}
|
|
|
|
// Partial-edit semantics for update: a field present in the form replaces the
|
|
// stored value, an absent one keeps it. On create `stored` is empty, so every
|
|
// absent field simply reads as empty/invalid and fails its own validation
|
|
fn text_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> String {
|
|
if fields.contains_key(key) {
|
|
field_str(fields, key)
|
|
} else {
|
|
stored[stored_key].as_str().unwrap_or("").to_string()
|
|
}
|
|
}
|
|
|
|
fn number_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> i64 {
|
|
if fields.contains_key(key) {
|
|
field_str(fields, key).parse::<i64>().unwrap_or(i64::MIN)
|
|
} else {
|
|
stored[stored_key].as_i64().unwrap_or(i64::MIN)
|
|
}
|
|
}
|
|
|
|
// Masterdata writes level-indexed skill arrays slash-separated ("25/24/24");
|
|
// an HTML form is more naturally comma-separated. Both are accepted
|
|
fn parse_levels(raw: &str, label: &str) -> Result<Vec<i64>, String> {
|
|
let mut rv = Vec::new();
|
|
for part in raw.split(['/', ',']) {
|
|
let part = part.trim();
|
|
if part.is_empty() {
|
|
continue;
|
|
}
|
|
let value = part.parse::<i64>().map_err(|_| format!("{}: '{}' is not a number", label, part))?;
|
|
if value < 0 || value > u32::MAX as i64 {
|
|
return Err(format!("{}: '{}' does not fit in a uint", label, part));
|
|
}
|
|
rv.push(value);
|
|
}
|
|
Ok(rv)
|
|
}
|
|
|
|
fn levels_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> Result<Vec<i64>, String> {
|
|
if fields.contains_key(key) {
|
|
return parse_levels(&field_str(fields, key), key);
|
|
}
|
|
Ok(stored[stored_key].members().filter_map(|v| v.as_i64()).collect())
|
|
}
|
|
|
|
fn to_json_array(values: &[i64]) -> JsonValue {
|
|
let mut rv = array![];
|
|
for value in values {
|
|
rv.push(*value).unwrap();
|
|
}
|
|
rv
|
|
}
|
|
|
|
// illust_id is {prefix:05}_{seq:04}_{00|01}, derived from the id and never
|
|
// uploaded
|
|
fn illust_id(master_card_id: i64, variant: &str) -> String {
|
|
format!("{:05}_{:04}_{}", master_card_id / 10000, master_card_id % 10000, variant)
|
|
}
|
|
|
|
struct PendingArt {
|
|
file: String,
|
|
entry: JsonValue,
|
|
bytes: Vec<u8>
|
|
}
|
|
|
|
fn pending(kind: &str, variant: Option<&str>, png: Vec<u8>) -> PendingArt {
|
|
let name = match variant {
|
|
Some(variant) => format!("{}_{}", kind, variant),
|
|
None => kind.to_string()
|
|
};
|
|
let mut entry = object!{
|
|
"kind": kind,
|
|
"md5": format!("{:x}", md5::compute(&png)),
|
|
"size": png.len()
|
|
};
|
|
if let Some(variant) = variant {
|
|
entry["variant"] = variant.into();
|
|
}
|
|
PendingArt {
|
|
file: format!("{}.png", name),
|
|
entry,
|
|
bytes: png
|
|
}
|
|
}
|
|
|
|
fn oversized(name: &str, bytes: &[u8]) -> Result<(), String> {
|
|
if bytes.len() > MAX_FILE_BYTES {
|
|
return Err(format!("'{}' exceeds the {} MB per-file limit", name, MAX_FILE_BYTES / (1024 * 1024)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// An explicit per-kind override: any decodable format, any dimensions -
|
|
// cover-cropped to the target aspect and resized, then stored as PNG
|
|
fn process_override(name: &str, bytes: &[u8], kind: &ArtKind) -> Result<Vec<u8>, String> {
|
|
oversized(name, bytes)?;
|
|
let img = art::decode_source(name, bytes)?;
|
|
art::encode_png(&art::cover(&img.to_rgba8(), kind.width, kind.height))
|
|
}
|
|
|
|
// Card art per variant: the source artwork (art_00 / art_01) derives all 7
|
|
// kinds via the import pipeline's recipes; explicit per-kind files override
|
|
// the derived ones. On create both sources are required; on update an absent
|
|
// source keeps the stored art (individual overrides still replace their kind)
|
|
fn collect_card_art(fields: &Fields, require_sources: bool) -> Result<Vec<PendingArt>, String> {
|
|
let mut rv = Vec::new();
|
|
for variant in CARD_ART_VARIANTS {
|
|
let source_name = format!("art_{}", variant);
|
|
let mut derived: HashMap<&'static str, image::RgbaImage> = HashMap::new();
|
|
if let Some(bytes) = file_of(fields, &source_name) {
|
|
oversized(&source_name, bytes)?;
|
|
let img = art::decode_source(&source_name, bytes)?;
|
|
derived = art::derive_card_art(&img).into_iter().collect();
|
|
} else if require_sources {
|
|
let label = if *variant == "00" { "normal" } else { "evolved" };
|
|
return Err(format!("'{}' ({} card artwork) is required", source_name, label));
|
|
}
|
|
for kind in CARD_ART {
|
|
let name = format!("{}_{}", kind.kind, variant);
|
|
let png = if let Some(bytes) = file_of(fields, &name) {
|
|
Some(process_override(&name, bytes, kind)?)
|
|
} else if let Some(img) = derived.remove(kind.kind) {
|
|
Some(art::encode_png(&img)?)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(png) = png {
|
|
rv.push(pending(kind.kind, Some(variant), png));
|
|
}
|
|
}
|
|
}
|
|
Ok(rv)
|
|
}
|
|
|
|
// Character art: the portrait (pr), signature (sign) and standing art
|
|
// (character) are distinct content and stay separate inputs; the icon is
|
|
// derived from the portrait unless explicitly supplied. Everything is
|
|
// cover-cropped to target, never rejected for dimensions
|
|
fn collect_character_art(fields: &Fields, require_all: bool) -> Result<Vec<PendingArt>, String> {
|
|
let mut rv = Vec::new();
|
|
let mut portrait: Option<image::DynamicImage> = None;
|
|
for kind in CHARACTER_ART {
|
|
let png = if let Some(bytes) = file_of(fields, kind.kind) {
|
|
oversized(kind.kind, bytes)?;
|
|
let img = art::decode_source(kind.kind, bytes)?;
|
|
let png = art::encode_png(&art::cover(&img.to_rgba8(), kind.width, kind.height))?;
|
|
if kind.kind == "pr" {
|
|
portrait = Some(img);
|
|
}
|
|
Some(png)
|
|
} else if kind.kind == "icon" {
|
|
// Derived from the portrait; on an update with no new portrait
|
|
// the stored icon stays
|
|
portrait.as_ref().map(|img| art::encode_png(&art::derive_character_icon(img))).transpose()?
|
|
} else if require_all {
|
|
return Err(format!("'{}' art is required", kind.kind));
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(png) = png {
|
|
rv.push(pending(kind.kind, None, png));
|
|
}
|
|
}
|
|
Ok(rv)
|
|
}
|
|
|
|
// The catalog's art list: the stored entries with every replaced file swapped
|
|
// out. The (kind, variant) pair is the client's cache key and md5 is the hash
|
|
// of the exact bytes the data route serves
|
|
fn merge_art(stored: &JsonValue, pending: &[PendingArt]) -> JsonValue {
|
|
let mut rv = array![];
|
|
for art in stored.members() {
|
|
if pending.iter().any(|new| new.entry["kind"] == art["kind"] && new.entry["variant"] == art["variant"]) {
|
|
continue;
|
|
}
|
|
rv.push(art.clone()).unwrap();
|
|
}
|
|
for art in pending {
|
|
rv.push(art.entry.clone()).unwrap();
|
|
}
|
|
rv
|
|
}
|
|
|
|
fn write_art(dir: &str, pending: &[PendingArt]) -> Result<(), String> {
|
|
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
for art in pending {
|
|
fs::write(format!("{}/{}", dir, art.file), &art.bytes).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// The character's voiceline set after applying this form: stored lines kept
|
|
// unless deleted, new files decoded + transcoded to ogg-vorbis, captions
|
|
// updated in place, and each kind's survivors renumbered 1..n. Returns the
|
|
// wire-shape array and the new (md5, ogg bytes) files to write
|
|
fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue, Vec<(String, Vec<u8>)>), String> {
|
|
let mut rv = array![];
|
|
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
|
|
for kind in VOICE_KINDS {
|
|
let mut lines: Vec<JsonValue> = Vec::new();
|
|
for index in 1..=MAX_VOICE_VARIANTS {
|
|
let base = format!("voice_{}_{}", kind, index);
|
|
let stored_line = stored_voice.members().find(|line| line["kind"] == *kind && line["index"] == index);
|
|
if field_flag(fields, &format!("{}_delete", base)) {
|
|
if file_of(fields, &base).is_some() {
|
|
return Err(format!("'{}': cannot both replace and delete the same line", base));
|
|
}
|
|
continue;
|
|
}
|
|
let text = |suffix: &str| -> String {
|
|
let key = format!("{}_{}", base, suffix);
|
|
if fields.contains_key(&key) {
|
|
field_str(fields, &key)
|
|
} else {
|
|
stored_line.map(|line| line[suffix].as_str().unwrap_or("").to_string()).unwrap_or_default()
|
|
}
|
|
};
|
|
// Captions are drawn as live subtitles by the same unescaped TMP path
|
|
for suffix in ["text", "text_en"] {
|
|
rich_text::reject_tags(&format!("'{}_{}'", base, suffix), &text(suffix), &[])?;
|
|
}
|
|
if let Some(bytes) = file_of(fields, &base) {
|
|
if bytes.len() > MAX_VOICE_BYTES {
|
|
return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024)));
|
|
}
|
|
let clip = audio::process_one_shot(bytes, MAX_VOICE_SECONDS).map_err(|e| format!("'{}': {}", base, e))?;
|
|
lines.push(object!{
|
|
"kind": *kind,
|
|
"index": 0,
|
|
"md5": clip.md5.clone(),
|
|
"size": clip.bytes.len(),
|
|
"text": text("text"),
|
|
"text_en": text("text_en")
|
|
});
|
|
files.push((clip.md5, clip.bytes));
|
|
} else if let Some(stored_line) = stored_line {
|
|
lines.push(object!{
|
|
"kind": *kind,
|
|
"index": 0,
|
|
"md5": stored_line["md5"].clone(),
|
|
"size": stored_line["size"].clone(),
|
|
"text": text("text"),
|
|
"text_en": text("text_en")
|
|
});
|
|
}
|
|
// Caption fields for a slot with neither a file nor a stored line
|
|
// are ignored, like unknown multipart fields everywhere else
|
|
}
|
|
for (i, mut line) in lines.into_iter().enumerate() {
|
|
line["index"] = (i as i64 + 1).into();
|
|
rv.push(line).unwrap();
|
|
}
|
|
}
|
|
Ok((rv, files))
|
|
}
|
|
|
|
fn voice_path(master_character_id: i64, md5: &str) -> String {
|
|
format!("{}/voice/{}.ogg", character_dir(master_character_id), md5)
|
|
}
|
|
|
|
fn write_voice(master_character_id: i64, files: &[(String, Vec<u8>)]) -> Result<(), String> {
|
|
if files.is_empty() {
|
|
return Ok(());
|
|
}
|
|
fs::create_dir_all(format!("{}/voice", character_dir(master_character_id))).map_err(|e| e.to_string())?;
|
|
for (md5, bytes) in files {
|
|
fs::write(voice_path(master_character_id, md5), bytes).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Files whose md5 no longer appears in the voice array are gone for good
|
|
// (their old catalog md5 404s, exactly like replaced art)
|
|
fn gc_voice(master_character_id: i64, old_voice: &JsonValue, new_voice: &JsonValue) {
|
|
for old in old_voice.members() {
|
|
let md5 = old["md5"].to_string();
|
|
if !md5.is_empty() && !new_voice.members().any(|line| line["md5"] == old["md5"]) {
|
|
let _ = fs::remove_file(voice_path(master_character_id, &md5));
|
|
}
|
|
}
|
|
}
|
|
|
|
// card.upload manages your own uploads, card.edit is moderation over anybody's
|
|
fn can_manage(uid: i64, owner: i64) -> bool {
|
|
permissions::has(uid, permissions::CARD_EDIT)
|
|
|| (owner == uid && permissions::has(uid, permissions::CARD_UPLOAD))
|
|
}
|
|
|
|
fn can_publish(uid: i64, owner: i64) -> bool {
|
|
permissions::has(uid, permissions::CARD_EDIT)
|
|
|| (owner == uid && permissions::has(uid, permissions::CARD_PUBLISH))
|
|
}
|
|
|
|
// A card may reference an official/imported character, or a custom one the
|
|
// uploader owns (or that is already publicly visible through a published card)
|
|
fn validate_character_ref(uid: i64, master_character_id: i64) -> Result<(), String> {
|
|
if OFFICIAL_CHARACTER_IDS.contains(&master_character_id) {
|
|
return Ok(());
|
|
}
|
|
if database::has_character(master_character_id)
|
|
&& (database::get_character_owner(master_character_id) == Some(uid)
|
|
|| database::character_publicly_visible(master_character_id)
|
|
|| permissions::has(uid, permissions::CARD_EDIT)) {
|
|
return Ok(());
|
|
}
|
|
Err(format!("Unknown master_character_id '{}'", master_character_id))
|
|
}
|
|
|
|
// Every referential and range check the client cannot survive being wrong
|
|
// about. Returns the catalog blob, ready to store and serve verbatim
|
|
fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
|
for (key, label) in [("name", "Card name"), ("name_en", "Card English name")] {
|
|
let text = text_of(fields, key, stored, key);
|
|
if text.is_empty() {
|
|
return Err(format!("{} is required", label));
|
|
}
|
|
// The client renders these through TMP with rich text on and no escaping; official card
|
|
// names carry no markup (rich_text.rs)
|
|
rich_text::reject_tags(label, &text, &[])?;
|
|
}
|
|
|
|
let card_type = number_of(fields, "type", stored, "type");
|
|
if !(CARD_TYPE_MIN..=CARD_TYPE_MAX).contains(&card_type) {
|
|
return Err(String::from("type must be 1-4 (1 Smile / 2 Pure / 3 Cool / 4 All)"));
|
|
}
|
|
|
|
let rarity = number_of(fields, "rarity", stored, "rarity");
|
|
if !(CARD_RARITY_MIN..=CARD_RARITY_MAX).contains(&rarity) {
|
|
return Err(String::from("rarity must be 1-3 (1 R / 2 SR / 3 UR)"));
|
|
}
|
|
let rarity_name = RARITY_NAMES[(rarity - 1) as usize];
|
|
|
|
// 0 is never a valid skill_center row: the client dereferences the mst
|
|
// with no null filter
|
|
let master_skill_center_id = number_of(fields, "master_skill_center_id", stored, "master_skill_center_id");
|
|
if !SKILL_CENTER_IDS.contains(&master_skill_center_id) {
|
|
return Err(format!("Unknown master_skill_center_id '{}'", master_skill_center_id));
|
|
}
|
|
|
|
// Note the official HP scale before assuming a bug report: hp is a tiny
|
|
// per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4)
|
|
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
|
|
let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)];
|
|
let mut values = Vec::new();
|
|
for (key, cap) in stats {
|
|
let value = number_of(fields, key, stored, key);
|
|
if !(1..=cap).contains(&value) {
|
|
return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name));
|
|
}
|
|
values.push(value);
|
|
}
|
|
|
|
let levels = *SKILL_LEVEL_COUNT.get(&rarity).unwrap_or(&0);
|
|
let stored_skill = stored["skill"].clone();
|
|
|
|
for (key, label) in [
|
|
("skill_name", "Skill name"),
|
|
("skill_name_en", "Skill English name"),
|
|
("skill_detail_text", "Skill description"),
|
|
("skill_detail_text_en", "Skill English description")
|
|
] {
|
|
let text = text_of(fields, key, &stored_skill, &key["skill_".len()..]);
|
|
if text.is_empty() {
|
|
return Err(format!("{} is required", label));
|
|
}
|
|
// Descriptions may wrap, like the official skill_center detailText rows; nothing else
|
|
let allowed: &[&str] = if key.ends_with("detail_text") || key.ends_with("detail_text_en") { &["br"] } else { &[] };
|
|
rich_text::reject_tags(label, &text, allowed)?;
|
|
}
|
|
|
|
let trigger = number_of(fields, "skill_trigger", &stored_skill, "trigger");
|
|
if !(SKILL_TRIGGER_MIN..=SKILL_TRIGGER_MAX).contains(&trigger) {
|
|
return Err(String::from("skill_trigger must be 1-4 (1 rhythm icons / 2 combo / 3 PERFECTs / 4 seconds)"));
|
|
}
|
|
let effect_type = number_of(fields, "skill_effect_type", &stored_skill, "effect_type");
|
|
if !(SKILL_EFFECT_TYPE_MIN..=SKILL_EFFECT_TYPE_MAX).contains(&effect_type) {
|
|
return Err(String::from("skill_effect_type must be 1-11 (1-3 stat up / 4 score / 5 perfect window / 6 heal / 7 skill chance / 8 skill boost / 9 param sync / 10 combo fever / 11 skill repeat)"));
|
|
}
|
|
let sub_target = number_of(fields, "skill_sub_target", &stored_skill, "sub_target");
|
|
if !(0..=SKILL_SUB_TARGET_MAX).contains(&sub_target) {
|
|
return Err(String::from("skill_sub_target must be 0 or 1 (0 every rhythm icon / 1 PERFECT icons only)"));
|
|
}
|
|
let target_group_id = number_of(fields, "skill_target_group_id", &stored_skill, "target_group_id");
|
|
if target_group_id != 0 && !GROUP_IDS.contains(&target_group_id) {
|
|
return Err(format!("skill_target_group_id must be 0 or a real group id, got '{}'", target_group_id));
|
|
}
|
|
let target_school_grade = number_of(fields, "skill_target_school_grade", &stored_skill, "target_school_grade");
|
|
if !(0..=SKILL_SCHOOL_GRADE_MAX).contains(&target_school_grade) {
|
|
return Err(String::from("skill_target_school_grade must be 0-3 (0 = no grade filter)"));
|
|
}
|
|
|
|
// The client walks these in lockstep with the skill level, so each must
|
|
// carry exactly one value per level of the rarity's curve (shipped
|
|
// masterdata also allows a single constant duration, so
|
|
// effective_milli_secs may be 1 long). MAGNITUDES are clamped to the
|
|
// ranges the shipped skill rows actually use - a 40-second buff and a
|
|
// 1e8 score-up both made it through before these bounds existed
|
|
let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1));
|
|
let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official"));
|
|
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
|
let bounds = [
|
|
("skill_trigger_value", "trigger_value", trigger_min, trigger_max,
|
|
format!("for trigger {} (the official range)", trigger)),
|
|
("skill_probability", "probability", prob_min, prob_max,
|
|
format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)),
|
|
("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max,
|
|
String::from("milliseconds (the official range)")),
|
|
("skill_effective_values", "effective_values", value_min, value_max,
|
|
format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source))
|
|
];
|
|
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
|
|
for (form, key, min, max, label) in bounds {
|
|
let values = levels_of(fields, form, &stored_skill, key)?;
|
|
if values.len() != levels && !(key == "effective_milli_secs" && values.len() == 1) {
|
|
return Err(format!("{} needs exactly {} values for a rarity {} card, got {}", form, levels, rarity, values.len()));
|
|
}
|
|
if let Some(value) = values.iter().find(|v| **v < min || **v > max) {
|
|
return Err(format!("{}: '{}' is out of range - values must be {}-{} {}", form, value, min, max, label));
|
|
}
|
|
arrays.insert(key, values);
|
|
}
|
|
|
|
Ok(object!{
|
|
"master_card_id": master_card_id,
|
|
"master_character_id": master_character_id,
|
|
"name": text_of(fields, "name", stored, "name"),
|
|
"name_en": text_of(fields, "name_en", stored, "name_en"),
|
|
"type": card_type,
|
|
"rarity": rarity,
|
|
"master_skill_center_id": master_skill_center_id,
|
|
"master_skill_id": master_card_id,
|
|
"hp": values[0],
|
|
"smile": values[1],
|
|
"cool": values[2],
|
|
"pure": values[3],
|
|
"illust_id": illust_id(master_card_id, "00"),
|
|
"evolve_illust_id": illust_id(master_card_id, "01"),
|
|
// Official cards use the level curve matching their rarity (1/2/3),
|
|
// verified across every row of card.csv
|
|
"master_card_level_id": rarity,
|
|
"unique_background_file_name": "",
|
|
"evolve_unique_background_file_name": "",
|
|
"get_category": GET_CATEGORY_GACHA,
|
|
"master_card_sys_voice_id": 0,
|
|
"album_unit_m_id": 0,
|
|
"priority": 0,
|
|
"master_release_label_id": MASTER_RELEASE_LABEL_ID,
|
|
"skill": {
|
|
"name": text_of(fields, "skill_name", &stored_skill, "name"),
|
|
"name_en": text_of(fields, "skill_name_en", &stored_skill, "name_en"),
|
|
"detail_text": text_of(fields, "skill_detail_text", &stored_skill, "detail_text"),
|
|
"detail_text_en": text_of(fields, "skill_detail_text_en", &stored_skill, "detail_text_en"),
|
|
"trigger": trigger,
|
|
"trigger_value": to_json_array(&arrays["trigger_value"]),
|
|
"probability": to_json_array(&arrays["probability"]),
|
|
"effective_milli_secs": to_json_array(&arrays["effective_milli_secs"]),
|
|
"sub_target": sub_target,
|
|
"target_group_id": target_group_id,
|
|
"target_school_grade": target_school_grade,
|
|
"effect_type": effect_type,
|
|
"effective_values": to_json_array(&arrays["effective_values"])
|
|
},
|
|
"art": JsonValue::Null // filled by the caller with merge_art
|
|
})
|
|
}
|
|
|
|
fn valid_color(color: &str) -> bool {
|
|
color.len() == 6 && color.chars().all(|c| c.is_ascii_hexdigit())
|
|
}
|
|
|
|
// Every column the uploader never supplies is forced to the value all 172
|
|
// imported characters carry. Numbers, not enum names
|
|
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
|
for (key, label) in [
|
|
("character_name", "Character name"),
|
|
("character_name_en", "Character English name"),
|
|
("character_name_ruby", "Character name reading"),
|
|
("character_name_ruby_en", "Character English name reading"),
|
|
("character_detail_text", "Character description"),
|
|
("character_detail_text_en", "Character English description"),
|
|
("character_name_richtext_gacha", "Character gacha display name"),
|
|
("character_name_richtext_gacha_en", "Character English gacha display name")
|
|
] {
|
|
let text = text_of(fields, key, stored, &key["character_".len()..]);
|
|
if text.is_empty() {
|
|
return Err(format!("{} is required", label));
|
|
}
|
|
// The gacha display name is the ONE column official data formats, and only ever with
|
|
// <size=NN> (character.csv nameRichtextGacha); descriptions may wrap; names carry nothing
|
|
let allowed: &[&str] = if key.starts_with("character_name_richtext_gacha") {
|
|
&["size"]
|
|
} else if key.starts_with("character_detail_text") {
|
|
&["br"]
|
|
} else {
|
|
&[]
|
|
};
|
|
rich_text::reject_tags(label, &text, allowed)?;
|
|
}
|
|
// The remaining free-text profile columns: shown on the member page, same TMP treatment
|
|
for key in ["height", "blood_type", "blood_type_en", "birthday", "birthday_en", "voice_actor", "voice_actor_en"] {
|
|
rich_text::reject_tags(key, &text_of(fields, &format!("character_{}", key), stored, key), &[])?;
|
|
}
|
|
for key in ["character_image_color", "character_image_color_dark"] {
|
|
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
|
|
return Err(format!("{} must be a 6-digit hex color like FF9210", key));
|
|
}
|
|
}
|
|
let text = |key: &str| text_of(fields, &format!("character_{}", key), stored, key);
|
|
Ok(object!{
|
|
"master_character_id": master_character_id,
|
|
"name": text("name"),
|
|
"name_en": text("name_en"),
|
|
"name_ruby": text("name_ruby"),
|
|
"name_ruby_en": text("name_ruby_en"),
|
|
"detail_text": text("detail_text"),
|
|
"detail_text_en": text("detail_text_en"),
|
|
"category": CHARACTER_CATEGORY_OTHER,
|
|
"school_grade": CHARACTER_SCHOOL_GRADE,
|
|
"chara_category": CHARACTER_CHARA_CATEGORY,
|
|
"master_group_id": CHARACTER_GROUP_ID,
|
|
"sprite_name": "",
|
|
"display_order": master_character_id,
|
|
"height": text("height"),
|
|
"blood_type": text("blood_type"),
|
|
"blood_type_en": text("blood_type_en"),
|
|
"birthday": text("birthday"),
|
|
"birthday_en": text("birthday_en"),
|
|
"voice_actor": text("voice_actor"),
|
|
"voice_actor_en": text("voice_actor_en"),
|
|
"image_color": text("image_color"),
|
|
"image_color_dark": text("image_color_dark"),
|
|
"name_richtext_gacha": text("name_richtext_gacha"),
|
|
"name_richtext_gacha_en": text("name_richtext_gacha_en"),
|
|
"master_release_label_id": MASTER_RELEASE_LABEL_ID,
|
|
"art": JsonValue::Null // filled by the caller with merge_art
|
|
})
|
|
}
|
|
|
|
pub fn create_card(uid: i64, fields: &Fields) -> Result<i64, String> {
|
|
if !permissions::has(uid, permissions::CARD_UPLOAD) {
|
|
return Err(String::from("You do not have permission to upload cards"));
|
|
}
|
|
if database::card_count_for_owner(uid) >= MAX_CARDS_PER_USER {
|
|
return Err(format!("You have reached the {} card limit", MAX_CARDS_PER_USER));
|
|
}
|
|
let published = field_flag(fields, "published");
|
|
let obtainable = field_flag(fields, "obtainable");
|
|
if (published || obtainable) && !can_publish(uid, uid) {
|
|
return Err(String::from("You do not have permission to publish cards"));
|
|
}
|
|
|
|
let master_character_id = field_str(fields, "master_character_id").parse::<i64>().unwrap_or(0);
|
|
validate_character_ref(uid, master_character_id)?;
|
|
|
|
// Fail fast: every cheap field/enum/range/reference check runs (as a
|
|
// dry-run against a placeholder id) BEFORE any image is decoded or
|
|
// derived, so a form mistake rejects instantly instead of after seconds
|
|
// of art processing
|
|
build_card(0, master_character_id, fields, &object!{})?;
|
|
|
|
let card_art = collect_card_art(fields, true)?;
|
|
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
let master_card_id = database::next_card_id();
|
|
if master_card_id > database::LAST_CARD_ID {
|
|
return Err(String::from("The custom card id space is exhausted"));
|
|
}
|
|
if !databases::CARD_LIST[master_card_id.to_string()].is_empty() {
|
|
return Err(format!("Card id {} already exists in masterdata", master_card_id));
|
|
}
|
|
|
|
let mut card = build_card(master_card_id, master_character_id, fields, &object!{})?;
|
|
card["art"] = merge_art(&array![], &card_art);
|
|
|
|
write_art(&card_dir(master_card_id), &card_art)?;
|
|
database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
|
|
Ok(master_card_id)
|
|
}
|
|
|
|
// Edit a card in place. The master_card_id - and everything derived from it:
|
|
// master_skill_id, illust ids - stays the same, so a player who owns the card
|
|
// keeps owning the same card. master_character_id is fixed too: repointing it
|
|
// would orphan a custom character mid-catalog
|
|
pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(), String> {
|
|
let Some(owner) = database::get_card_owner(master_card_id) else {
|
|
return Err(String::from("Card not found"));
|
|
};
|
|
if !can_manage(uid, owner) {
|
|
return Err(String::from("You do not have permission to edit this card"));
|
|
}
|
|
let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
|
|
let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0);
|
|
|
|
// Field validation first (cheap), art processing second - fail fast
|
|
let mut card = build_card(master_card_id, master_character_id, fields, &stored)?;
|
|
let card_art = collect_card_art(fields, false)?;
|
|
card["art"] = merge_art(&stored["art"], &card_art);
|
|
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
write_art(&card_dir(master_card_id), &card_art)?;
|
|
database::update_card(master_card_id, &card);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Publish/unpublish and the obtainable toggle share a route: both are
|
|
// catalog-flag flips on an owned card
|
|
pub fn set_card_flags(uid: i64, master_card_id: i64, published: Option<bool>, obtainable: Option<bool>) -> Result<(), String> {
|
|
let Some(owner) = database::get_card_owner(master_card_id) else {
|
|
return Err(String::from("Card not found"));
|
|
};
|
|
if !can_publish(uid, owner) {
|
|
return Err(String::from("You do not have permission to publish this card"));
|
|
}
|
|
if let Some(published) = published {
|
|
database::set_published(master_card_id, published);
|
|
}
|
|
if let Some(obtainable) = obtainable {
|
|
database::set_obtainable(master_card_id, obtainable);
|
|
}
|
|
database::bump_revision();
|
|
Ok(())
|
|
}
|
|
|
|
// Deleting retires the id forever. Player copies of the dead card are wiped
|
|
// lazily on each account's next userdata pull (userdata::remove_deleted_
|
|
// custom_cards), mirroring how deleted custom songs clean up. The character
|
|
// stays: it may back other cards, and has its own delete route
|
|
pub fn delete_card(uid: i64, master_card_id: i64) -> Result<(), String> {
|
|
let Some(owner) = database::get_card_owner(master_card_id) else {
|
|
return Err(String::from("Card not found"));
|
|
};
|
|
if !can_manage(uid, owner) {
|
|
return Err(String::from("You do not have permission to delete this card"));
|
|
}
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
database::delete_card(master_card_id);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
let _ = fs::remove_dir_all(card_dir(master_card_id));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn create_character(uid: i64, fields: &Fields) -> Result<i64, String> {
|
|
if !permissions::has(uid, permissions::CARD_UPLOAD) {
|
|
return Err(String::from("You do not have permission to upload characters"));
|
|
}
|
|
// Fail fast: cheap text validation before any image/audio work
|
|
build_character(0, fields, &object!{})?;
|
|
let character_art = collect_character_art(fields, true)?;
|
|
let (voice, voice_files) = collect_voice(fields, &array![])?;
|
|
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
let master_character_id = database::next_character_id();
|
|
if master_character_id > database::LAST_CHARACTER_ID {
|
|
return Err(String::from("The custom character id space is exhausted"));
|
|
}
|
|
if OFFICIAL_CHARACTER_IDS.contains(&master_character_id) {
|
|
return Err(format!("Character id {} already exists in masterdata", master_character_id));
|
|
}
|
|
|
|
let mut character = build_character(master_character_id, fields, &object!{})?;
|
|
character["art"] = merge_art(&array![], &character_art);
|
|
if !voice.is_empty() {
|
|
character["voice"] = voice;
|
|
}
|
|
|
|
write_art(&character_dir(master_character_id), &character_art)?;
|
|
write_voice(master_character_id, &voice_files)?;
|
|
database::insert_character(master_character_id, uid, &character);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
|
|
Ok(master_character_id)
|
|
}
|
|
|
|
pub fn update_character(uid: i64, master_character_id: i64, fields: &Fields) -> Result<(), String> {
|
|
let Some(owner) = database::get_character_owner(master_character_id) else {
|
|
return Err(String::from("Character not found"));
|
|
};
|
|
if !can_manage(uid, owner) {
|
|
return Err(String::from("You do not have permission to edit this character"));
|
|
}
|
|
let stored = database::get_character(master_character_id).ok_or(String::from("Character not found"))?;
|
|
|
|
// Field validation first (cheap), image/audio processing second - fail fast
|
|
let mut character = build_character(master_character_id, fields, &stored)?;
|
|
let character_art = collect_character_art(fields, false)?;
|
|
let (voice, voice_files) = collect_voice(fields, &stored["voice"])?;
|
|
character["art"] = merge_art(&stored["art"], &character_art);
|
|
if !voice.is_empty() {
|
|
character["voice"] = voice.clone();
|
|
}
|
|
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
write_art(&character_dir(master_character_id), &character_art)?;
|
|
write_voice(master_character_id, &voice_files)?;
|
|
database::update_character(master_character_id, &character);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
|
|
// Replaced/deleted lines: their oggs are per-character, so a md5 gone
|
|
// from the blob is safe to remove
|
|
gc_voice(master_character_id, &stored["voice"], &voice);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// A character can only go once nothing references it - a dangling
|
|
// master_character_id in a served card is a client crash
|
|
pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String> {
|
|
let Some(owner) = database::get_character_owner(master_character_id) else {
|
|
return Err(String::from("Character not found"));
|
|
};
|
|
if !can_manage(uid, owner) {
|
|
return Err(String::from("You do not have permission to delete this character"));
|
|
}
|
|
let referenced = database::cards_using_character(master_character_id);
|
|
if referenced > 0 {
|
|
return Err(format!("{} card(s) still use this character - delete them first", referenced));
|
|
}
|
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
|
database::delete_character(master_character_id);
|
|
database::bump_revision();
|
|
drop(lock);
|
|
let _ = fs::remove_dir_all(character_dir(master_character_id));
|
|
Ok(())
|
|
}
|
|
|
|
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let fields = match read_multipart(payload).await {
|
|
Ok(fields) => fields,
|
|
Err(e) => return webui::error(&e)
|
|
};
|
|
match create_card(uid, &fields) {
|
|
Ok(master_card_id) => send_json(object!{
|
|
result: "OK",
|
|
master_card_id: master_card_id
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let fields = match read_multipart(payload).await {
|
|
Ok(fields) => fields,
|
|
Err(e) => return webui::error(&e)
|
|
};
|
|
let master_card_id = field_str(&fields, "master_card_id").parse::<i64>().unwrap_or(0);
|
|
match update_card(uid, master_card_id, &fields) {
|
|
Ok(()) => send_json(object!{
|
|
result: "OK",
|
|
master_card_id: master_card_id
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn publish(req: HttpRequest, body: String) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
|
let master_card_id = body["master_card_id"].as_i64().unwrap_or(0);
|
|
match set_card_flags(uid, master_card_id, body["published"].as_bool(), body["obtainable"].as_bool()) {
|
|
Ok(()) => send_json(object!{
|
|
result: "OK"
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
|
match delete_card(uid, body["master_card_id"].as_i64().unwrap_or(0)) {
|
|
Ok(()) => send_json(object!{
|
|
result: "OK"
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn character_create(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let fields = match read_multipart(payload).await {
|
|
Ok(fields) => fields,
|
|
Err(e) => return webui::error(&e)
|
|
};
|
|
match create_character(uid, &fields) {
|
|
Ok(master_character_id) => send_json(object!{
|
|
result: "OK",
|
|
master_character_id: master_character_id
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn character_update(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let fields = match read_multipart(payload).await {
|
|
Ok(fields) => fields,
|
|
Err(e) => return webui::error(&e)
|
|
};
|
|
let master_character_id = field_str(&fields, "master_character_id").parse::<i64>().unwrap_or(0);
|
|
match update_character(uid, master_character_id, &fields) {
|
|
Ok(()) => send_json(object!{
|
|
result: "OK",
|
|
master_character_id: master_character_id
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn character_delete(req: HttpRequest, body: String) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
|
match delete_character(uid, body["master_character_id"].as_i64().unwrap_or(0)) {
|
|
Ok(()) => send_json(object!{
|
|
result: "OK"
|
|
}),
|
|
Err(e) => webui::error(&e)
|
|
}
|
|
}
|
|
|
|
async fn mine(req: HttpRequest) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let Some(uid) = get_session_uid(&req) else {
|
|
return webui::error("Not logged in");
|
|
};
|
|
send_json(object!{
|
|
result: "OK",
|
|
cards: database::get_cards_by_owner(uid),
|
|
characters: database::get_characters_by_owner(uid)
|
|
})
|
|
}
|
|
|
|
// The public card browser: the published catalog with uploader names, plus
|
|
// the custom characters those cards reference. Anonymous viewers are fine -
|
|
// published means public
|
|
async fn browse(_req: HttpRequest) -> HttpResponse {
|
|
if disabled() {
|
|
return HttpResponse::NotFound().finish();
|
|
}
|
|
let mut cards = database::get_browse_cards();
|
|
for card in cards.members_mut() {
|
|
card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone();
|
|
card.remove("owner_id");
|
|
}
|
|
let characters = database::get_characters_for_cards(0, &cards);
|
|
send_json(object!{
|
|
result: "OK",
|
|
cards: cards,
|
|
characters: characters
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub mod tests {
|
|
use super::*;
|
|
|
|
// Distinct bytes per file, so every art entry gets its own md5 and a
|
|
// content-addressed lookup can be asserted per kind
|
|
pub fn seeded_png(width: u32, height: u32, seed: u8) -> Vec<u8> {
|
|
let mut rv = Vec::new();
|
|
image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(width, height, |x, y| {
|
|
image::Rgba([(x % 256) as u8, (y % 256) as u8, seed, 255])
|
|
})).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
|
|
rv
|
|
}
|
|
|
|
// A navi-style source: transparent background, opaque figure - exercises
|
|
// the cutout derivation lane. Deliberately odd-sized: every derived kind
|
|
// must still come out at the exact official target size
|
|
pub fn cutout_png(width: u32, height: u32, seed: u8) -> Vec<u8> {
|
|
let mut img = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
|
|
for y in height / 8..height * 7 / 8 {
|
|
for x in width / 3..width * 2 / 3 {
|
|
img.put_pixel(x, y, image::Rgba([(x % 256) as u8, (y % 256) as u8, seed, 255]));
|
|
}
|
|
}
|
|
let mut rv = Vec::new();
|
|
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
|
|
rv
|
|
}
|
|
|
|
pub fn field(fields: &mut Fields, key: &str, value: &str) {
|
|
fields.insert(String::from(key), value.as_bytes().to_vec());
|
|
}
|
|
|
|
// A complete, valid rarity-1 card upload
|
|
pub fn base_fields() -> Fields {
|
|
let mut fields = Fields::new();
|
|
field(&mut fields, "name", "Test Card");
|
|
field(&mut fields, "name_en", "Test Card EN");
|
|
field(&mut fields, "master_character_id", "1001");
|
|
field(&mut fields, "type", "1");
|
|
field(&mut fields, "rarity", "1");
|
|
field(&mut fields, "hp", "2");
|
|
field(&mut fields, "smile", "1000");
|
|
field(&mut fields, "cool", "1000");
|
|
field(&mut fields, "pure", "1000");
|
|
field(&mut fields, "master_skill_center_id", "100001");
|
|
field(&mut fields, "skill_name", "Test Skill");
|
|
field(&mut fields, "skill_name_en", "Test Skill EN");
|
|
field(&mut fields, "skill_detail_text", "Does a thing");
|
|
field(&mut fields, "skill_detail_text_en", "Does a thing in English");
|
|
field(&mut fields, "skill_trigger", "3");
|
|
field(&mut fields, "skill_trigger_value", "25/24/24");
|
|
field(&mut fields, "skill_probability", "39000/39000/39000");
|
|
field(&mut fields, "skill_effective_milli_secs", "2000/2000/2000");
|
|
field(&mut fields, "skill_sub_target", "0");
|
|
field(&mut fields, "skill_target_group_id", "0");
|
|
field(&mut fields, "skill_target_school_grade", "0");
|
|
field(&mut fields, "skill_effect_type", "4");
|
|
field(&mut fields, "skill_effective_values", "124/126/128");
|
|
// Odd-sized sources: the server derives all 7 kinds per variant
|
|
fields.insert(String::from("art_00"), cutout_png(870, 1100, 10));
|
|
fields.insert(String::from("art_01"), seeded_png(1333, 987, 20));
|
|
fields
|
|
}
|
|
|
|
pub fn character_fields() -> Fields {
|
|
let mut fields = Fields::new();
|
|
field(&mut fields, "character_name", "Test Chara");
|
|
field(&mut fields, "character_name_en", "Test Chara EN");
|
|
field(&mut fields, "character_name_ruby", "てすと");
|
|
field(&mut fields, "character_name_ruby_en", "Tesuto");
|
|
field(&mut fields, "character_detail_text", "A test character.");
|
|
field(&mut fields, "character_detail_text_en", "A test character (EN).");
|
|
field(&mut fields, "character_name_richtext_gacha", "Test Chara");
|
|
field(&mut fields, "character_name_richtext_gacha_en", "Test Chara");
|
|
field(&mut fields, "character_height", "");
|
|
field(&mut fields, "character_blood_type", "");
|
|
field(&mut fields, "character_blood_type_en", "");
|
|
field(&mut fields, "character_birthday", "?月?日");
|
|
field(&mut fields, "character_birthday_en", "");
|
|
field(&mut fields, "character_voice_actor", "");
|
|
field(&mut fields, "character_voice_actor_en", "");
|
|
field(&mut fields, "character_image_color", "888888");
|
|
field(&mut fields, "character_image_color_dark", "888888");
|
|
// Odd sizes on purpose; no icon - it derives from the portrait
|
|
fields.insert(String::from("pr"), seeded_png(431, 617, 201));
|
|
fields.insert(String::from("sign"), seeded_png(600, 500, 202));
|
|
fields.insert(String::from("character"), seeded_png(555, 999, 203));
|
|
fields
|
|
}
|
|
|
|
// Permission grants need a grantor; an owner uid is the bootstrap one.
|
|
// Owners are cleared afterwards so unrelated tests never see them
|
|
pub fn with_permissions<T>(uid: i64, scopes: &[&str], body: impl FnOnce() -> T) -> T {
|
|
crate::runtime::update_owners(&[9_000_001]);
|
|
for scope in scopes {
|
|
permissions::grant(uid, scope, 9_000_001).unwrap();
|
|
}
|
|
let rv = body();
|
|
for scope in scopes {
|
|
let _ = permissions::revoke(uid, scope, 9_000_001);
|
|
}
|
|
crate::runtime::update_owners(&[]);
|
|
rv
|
|
}
|
|
|
|
pub fn wipe(uid: i64) {
|
|
crate::runtime::update_owners(&[uid]);
|
|
for card in database::get_cards_by_owner(uid).members() {
|
|
let _ = delete_card(uid, card["master_card_id"].as_i64().unwrap());
|
|
}
|
|
for character in database::get_characters_by_owner(uid).members() {
|
|
let _ = delete_character(uid, character["master_character_id"].as_i64().unwrap());
|
|
}
|
|
crate::runtime::update_owners(&[]);
|
|
}
|
|
|
|
// Seeded 44.1kHz 16-bit mono wav, so different seeds give different md5s
|
|
pub fn test_wav(seconds: f64, seed: u8) -> Vec<u8> {
|
|
let sample_rate: u32 = 44100;
|
|
let frames = (seconds * sample_rate as f64) as u32;
|
|
let data_len = frames * 2;
|
|
let mut rv = Vec::new();
|
|
rv.extend(b"RIFF");
|
|
rv.extend((36 + data_len).to_le_bytes());
|
|
rv.extend(b"WAVEfmt ");
|
|
rv.extend(16u32.to_le_bytes());
|
|
rv.extend(1u16.to_le_bytes());
|
|
rv.extend(1u16.to_le_bytes());
|
|
rv.extend(sample_rate.to_le_bytes());
|
|
rv.extend((sample_rate * 2).to_le_bytes());
|
|
rv.extend(2u16.to_le_bytes());
|
|
rv.extend(16u16.to_le_bytes());
|
|
rv.extend(b"data");
|
|
rv.extend(data_len.to_le_bytes());
|
|
for i in 0..frames {
|
|
let sample = (((i as f64 * (220.0 + seed as f64) * 2.0 * std::f64::consts::PI / sample_rate as f64).sin()) * 8000.0) as i16;
|
|
rv.extend(sample.to_le_bytes());
|
|
}
|
|
rv
|
|
}
|
|
|
|
// Character text and voiceline captions land in the same unescaped TMP labels as card text.
|
|
// The gacha display name is the one column official data formats, and only with <size=NN>
|
|
#[test]
|
|
fn character_text_may_not_carry_rich_text_tags() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4014);
|
|
|
|
let run = |fields: &Fields| with_permissions(4014, &[permissions::CARD_UPLOAD], || create_character(4014, fields));
|
|
let mutated = |key: &str, value: &str| {
|
|
let mut fields = character_fields();
|
|
field(&mut fields, key, value);
|
|
fields
|
|
};
|
|
|
|
for key in ["character_name", "character_name_en", "character_name_ruby", "character_name_ruby_en"] {
|
|
let error = run(&mutated(key, "<size=400%>x")).unwrap_err();
|
|
assert!(error.contains("<size>"), "{} -> {}", key, error);
|
|
}
|
|
// Profile columns too
|
|
assert!(run(&mutated("character_height", "<b>170cm</b>")).unwrap_err().contains("<b>"));
|
|
assert!(run(&mutated("character_voice_actor", "<sprite=2>")).unwrap_err().contains("<sprite>"));
|
|
// Descriptions wrap; nothing else
|
|
assert!(run(&mutated("character_detail_text", "<rotate=45>x")).unwrap_err().contains("<rotate>"));
|
|
// The gacha display name keeps <size>, still nothing else
|
|
assert!(run(&mutated("character_name_richtext_gacha", "<sprite=1>")).unwrap_err().contains("<sprite>"));
|
|
|
|
// Voiceline captions are subtitles - a caption with a tag is rejected before anything
|
|
// is written
|
|
let mut fields = character_fields();
|
|
fields.insert(String::from("voice_live_start_1"), test_wav(1.0, 1));
|
|
field(&mut fields, "voice_live_start_1_text", "<color=red>x");
|
|
assert!(run(&fields).unwrap_err().contains("<color>"));
|
|
|
|
// The official shape uploads: <size=NN> on the gacha name, <br> in a description
|
|
let mut fields = character_fields();
|
|
field(&mut fields, "character_name_richtext_gacha", "<size=80>Test</size>");
|
|
field(&mut fields, "character_detail_text", "line one<br>line two");
|
|
let id = run(&fields).unwrap();
|
|
let character = database::get_character(id).unwrap();
|
|
assert_eq!(character["name_richtext_gacha"].as_str(), Some("<size=80>Test</size>"));
|
|
assert_eq!(character["detail_text"].as_str(), Some("line one<br>line two"));
|
|
}
|
|
|
|
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
|
|
// caption-only edits, replacement GC, deletion, the caps, and the
|
|
// content-addressed voice route index
|
|
#[test]
|
|
fn character_voicelines() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4010);
|
|
|
|
let mut fields = character_fields();
|
|
// Sparse indexes on purpose: 1 and 5 must renumber to 1 and 2
|
|
fields.insert(String::from("voice_live_start_1"), test_wav(1.0, 1));
|
|
field(&mut fields, "voice_live_start_1_text", "いくよー!");
|
|
field(&mut fields, "voice_live_start_1_text_en", "Here we go!");
|
|
fields.insert(String::from("voice_live_start_5"), test_wav(1.0, 2));
|
|
fields.insert(String::from("voice_skill_smile_1"), test_wav(0.5, 3));
|
|
// Index 10 is out of range and ignored entirely
|
|
fields.insert(String::from("voice_live_start_10"), test_wav(1.0, 4));
|
|
let id = with_permissions(4010, &[permissions::CARD_UPLOAD], || create_character(4010, &fields).unwrap());
|
|
|
|
let character = database::get_character(id).unwrap();
|
|
let voice = &character["voice"];
|
|
assert_eq!(voice.len(), 3);
|
|
let live: Vec<&JsonValue> = voice.members().filter(|line| line["kind"] == "live_start").collect();
|
|
assert_eq!(live.len(), 2);
|
|
assert_eq!(live[0]["index"].as_i64(), Some(1));
|
|
assert_eq!(live[1]["index"].as_i64(), Some(2));
|
|
assert_eq!(live[0]["text"].as_str(), Some("いくよー!"));
|
|
assert_eq!(live[0]["text_en"].as_str(), Some("Here we go!"));
|
|
assert_eq!(live[1]["text"].as_str(), Some(""));
|
|
for line in voice.members() {
|
|
let md5 = line["md5"].to_string();
|
|
assert_eq!(md5.len(), 32);
|
|
let path = format!("{}/voice/{}.ogg", character_dir(id), md5);
|
|
let bytes = fs::read(&path).unwrap();
|
|
assert!(bytes.starts_with(b"OggS"), "transcoded to ogg");
|
|
assert_eq!(format!("{:x}", md5::compute(&bytes)), md5);
|
|
assert_eq!(bytes.len(), line["size"].as_usize().unwrap());
|
|
// The voice route's index resolves the md5 to this exact file
|
|
assert_eq!(database::find_voice_by_md5(&md5), Some(format!("characters/{}/voice/{}.ogg", id, md5)));
|
|
}
|
|
|
|
// Caption-only edit: md5 untouched, captions replaced
|
|
let old_md5 = live[0]["md5"].to_string();
|
|
let mut edit = Fields::new();
|
|
field(&mut edit, "voice_live_start_1_text_en", "Let's gooo!");
|
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
|
let line = database::get_character(id).unwrap()["voice"].members()
|
|
.find(|line| line["kind"] == "live_start" && line["index"] == 1).unwrap().clone();
|
|
assert_eq!(line["md5"].to_string(), old_md5);
|
|
assert_eq!(line["text_en"].as_str(), Some("Let's gooo!"));
|
|
assert_eq!(line["text"].as_str(), Some("いくよー!"));
|
|
|
|
// Replacing a line swaps the md5 and garbage-collects the old ogg
|
|
let mut edit = Fields::new();
|
|
edit.insert(String::from("voice_live_start_1"), test_wav(1.0, 9));
|
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
|
let updated = database::get_character(id).unwrap();
|
|
let new_md5 = updated["voice"].members()
|
|
.find(|line| line["kind"] == "live_start" && line["index"] == 1).unwrap()["md5"].to_string();
|
|
assert_ne!(new_md5, old_md5);
|
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), old_md5)).is_err());
|
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), new_md5)).is_ok());
|
|
assert_eq!(database::find_voice_by_md5(&old_md5), None);
|
|
|
|
// Deleting line 2 removes its file and leaves a contiguous single line
|
|
let gone_md5 = updated["voice"].members()
|
|
.find(|line| line["kind"] == "live_start" && line["index"] == 2).unwrap()["md5"].to_string();
|
|
let mut edit = Fields::new();
|
|
field(&mut edit, "voice_live_start_2_delete", "1");
|
|
with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit).unwrap());
|
|
let after = database::get_character(id).unwrap();
|
|
let live: Vec<JsonValue> = after["voice"].members().filter(|line| line["kind"] == "live_start").cloned().collect();
|
|
assert_eq!(live.len(), 1);
|
|
assert_eq!(live[0]["index"].as_i64(), Some(1));
|
|
assert!(fs::read(format!("{}/voice/{}.ogg", character_dir(id), gone_md5)).is_err());
|
|
|
|
// Replace + delete on the same slot is contradictory
|
|
let mut edit = Fields::new();
|
|
field(&mut edit, "voice_live_start_1_delete", "1");
|
|
edit.insert(String::from("voice_live_start_1"), test_wav(1.0, 5));
|
|
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
|
|
.unwrap_err().contains("cannot both replace and delete"));
|
|
|
|
// The caps: over-long and undecodable clips are refused by name
|
|
let mut edit = Fields::new();
|
|
edit.insert(String::from("voice_result_bond_1"), test_wav(31.0, 6));
|
|
let err = with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit)).unwrap_err();
|
|
assert!(err.contains("voice_result_bond_1") && err.contains("maximum is 30"), "{}", err);
|
|
let mut edit = Fields::new();
|
|
edit.insert(String::from("voice_result_bond_1"), b"definitely not audio".to_vec());
|
|
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
|
|
.unwrap_err().contains("Could not read audio file"));
|
|
|
|
assert_eq!(database::find_voice_by_md5(&"0".repeat(32)), None);
|
|
wipe(4010);
|
|
}
|
|
|
|
// Diagnostic + sanity guard for the derived skill magnitude envelopes
|
|
#[test]
|
|
fn skill_ranges_derive_from_shipped_rows() {
|
|
println!("effect_values:");
|
|
for effect in 1..=11i64 {
|
|
let (min, max, source) = SKILL_VALUE_RANGES.get(&effect).copied().unwrap_or((0, 0, "MISSING"));
|
|
println!(" {} ({}): {}..{} [{}]", effect, EFFECT_NAMES[effect as usize], min, max, source);
|
|
assert!(min >= 1 && max >= min, "effect {} has no sane range", effect);
|
|
}
|
|
println!("trigger_values:");
|
|
for trigger in 1..=4i64 {
|
|
let (min, max) = SKILL_TRIGGER_RANGES.get(&trigger).copied().unwrap_or((0, 0));
|
|
println!(" {}: {}..{}", trigger, min, max);
|
|
assert!(min >= 1 && max >= min, "trigger {} has no sane range", trigger);
|
|
}
|
|
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
|
|
println!("probability: {}..{}", prob_min, prob_max);
|
|
println!("milli_secs: {}..{}", ms_min, ms_max);
|
|
// The two incidents must be outside whatever derives
|
|
assert!(prob_min >= 1000 && prob_max <= 100_000);
|
|
assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range");
|
|
let (_, value_max, _) = SKILL_VALUE_RANGES[&4];
|
|
assert!(value_max < 100_000_000, "1e8 score-ups must be out of range");
|
|
}
|
|
|
|
// A full create: derived ids, pinned columns, art md5s and the catalog
|
|
// shape the client parses
|
|
#[test]
|
|
fn create_builds_the_catalog_entry() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4001);
|
|
|
|
let fields = base_fields();
|
|
let id = with_permissions(4001, &[permissions::CARD_UPLOAD], || create_card(4001, &fields).unwrap());
|
|
assert!(id >= database::FIRST_CARD_ID);
|
|
assert!(id / 10000 >= database::FIRST_ILLUST_PREFIX);
|
|
|
|
let card = database::get_card(id).unwrap();
|
|
assert_eq!(card["master_card_id"].as_i64(), Some(id));
|
|
assert_eq!(card["master_skill_id"].as_i64(), Some(id));
|
|
assert_eq!(card["master_character_id"].as_i64(), Some(1001));
|
|
assert_eq!(card["illust_id"].to_string(), format!("{:05}_{:04}_00", id / 10000, id % 10000));
|
|
assert_eq!(card["evolve_illust_id"].to_string(), format!("{:05}_{:04}_01", id / 10000, id % 10000));
|
|
assert_eq!(card["master_card_level_id"].as_i64(), Some(1));
|
|
assert_eq!(card["master_release_label_id"].as_i64(), Some(1));
|
|
assert_eq!(card["get_category"].as_i64(), Some(GET_CATEGORY_GACHA));
|
|
assert_eq!(card["unique_background_file_name"].as_str(), Some(""));
|
|
assert_eq!(card["skill"]["effective_values"].len(), 3);
|
|
assert_eq!(card["skill"]["effect_type"].as_i64(), Some(4));
|
|
// All 7 kinds, both variants
|
|
assert_eq!(card["art"].len(), 14);
|
|
// A draft: owner-only, not obtainable, never resolvable by viewers
|
|
assert_eq!(card["obtainable"].as_bool(), Some(false));
|
|
assert!(!database::is_published(id));
|
|
assert!(!viewer_can_resolve(id, PROTOCOL_VERSION));
|
|
|
|
// Every art entry hashes the exact processed bytes on disk, the data
|
|
// route index resolves the md5 back to that file, and every derived
|
|
// kind landed at its exact official size despite the odd-sized source
|
|
for art in card["art"].members() {
|
|
let path = format!("{}/{}_{}.png", card_dir(id), art["kind"], art["variant"]);
|
|
let bytes = fs::read(&path).unwrap();
|
|
assert_eq!(format!("{:x}", md5::compute(&bytes)), art["md5"].to_string());
|
|
assert_eq!(bytes.len(), art["size"].as_usize().unwrap());
|
|
let resolved = database::find_asset_by_md5(&art["md5"].to_string()).unwrap();
|
|
assert_eq!(fs::read(asset_path(&resolved)).unwrap(), bytes);
|
|
let img = image::load_from_memory(&bytes).unwrap();
|
|
let target = CARD_ART.iter().find(|k| art["kind"] == k.kind).unwrap();
|
|
assert_eq!((img.width(), img.height()), (target.width, target.height), "kind {}", art["kind"]);
|
|
}
|
|
|
|
// card_info synthesizes the csv shape for the runtime band
|
|
let info = card_info(id);
|
|
assert_eq!(info["rarity"].as_i64(), Some(1));
|
|
assert_eq!(info["masterCardLevelId"].as_i64(), Some(1));
|
|
assert_eq!(info["masterCharacterId"].as_i64(), Some(1001));
|
|
assert_eq!(crate::router::items::get_rarity(id), 1);
|
|
// Official rows still come from the baked table
|
|
assert_eq!(card_info(10010001)["rarity"].as_i64(), Some(1));
|
|
assert!(card_info(id + 5000).is_null());
|
|
|
|
with_permissions(4001, &[permissions::CARD_PUBLISH], || set_card_flags(4001, id, Some(true), Some(true)).unwrap());
|
|
assert!(viewer_can_resolve(id, PROTOCOL_VERSION));
|
|
// A protocol-2 viewer can't fetch the catalog, so it can't resolve it
|
|
assert!(!viewer_can_resolve(id, 2));
|
|
assert_eq!(database::obtainable_card_ids(1), vec![id]);
|
|
|
|
wipe(4001);
|
|
}
|
|
|
|
// A new character via its own route: pinned numeric columns, art, and the
|
|
// reference/deletion lifecycle with a card built on it
|
|
#[test]
|
|
fn character_lifecycle() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4002);
|
|
|
|
let character_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_character(4002, &character_fields()).unwrap());
|
|
assert!(character_id >= database::FIRST_CHARACTER_ID);
|
|
assert!(!OFFICIAL_CHARACTER_IDS.contains(&character_id));
|
|
|
|
let character = database::get_character(character_id).unwrap();
|
|
assert_eq!(character["category"].as_i64(), Some(6));
|
|
assert_eq!(character["chara_category"].as_i64(), Some(1));
|
|
assert_eq!(character["master_group_id"].as_i64(), Some(9000));
|
|
assert_eq!(character["school_grade"].as_i64(), Some(0));
|
|
assert_eq!(character["sprite_name"].as_str(), Some(""));
|
|
assert_eq!(character["display_order"].as_i64(), Some(character_id));
|
|
assert_eq!(character["master_release_label_id"].as_i64(), Some(1));
|
|
// All 4 kinds exist even though only 3 inputs were supplied: the icon
|
|
// derives from the portrait
|
|
assert_eq!(character["art"].len(), 4);
|
|
assert!(character["art"].members().any(|art| art["kind"] == "icon"));
|
|
// Numbers, not enum names - a name here is a live-start softlock
|
|
for key in ["category", "chara_category", "master_group_id", "school_grade"] {
|
|
assert!(character[key].as_i64().is_some(), "{} is not numeric", key);
|
|
}
|
|
// Processed bytes are what's hashed, and every odd-sized input landed
|
|
// at its exact official size
|
|
for art in character["art"].members() {
|
|
let path = format!("{}/{}.png", character_dir(character_id), art["kind"]);
|
|
let bytes = fs::read(&path).unwrap();
|
|
assert_eq!(format!("{:x}", md5::compute(&bytes)), art["md5"].to_string());
|
|
let img = image::load_from_memory(&bytes).unwrap();
|
|
let target = CHARACTER_ART.iter().find(|k| art["kind"] == k.kind).unwrap();
|
|
assert_eq!((img.width(), img.height()), (target.width, target.height), "kind {}", art["kind"]);
|
|
}
|
|
|
|
// An explicit icon override beats the derived one
|
|
let derived_icon = character["art"].members().find(|art| art["kind"] == "icon").unwrap()["md5"].to_string();
|
|
let mut edit = Fields::new();
|
|
edit.insert(String::from("icon"), seeded_png(300, 300, 210));
|
|
with_permissions(4002, &[permissions::CARD_UPLOAD], || update_character(4002, character_id, &edit).unwrap());
|
|
let updated = database::get_character(character_id).unwrap();
|
|
let new_icon = updated["art"].members().find(|art| art["kind"] == "icon").unwrap()["md5"].to_string();
|
|
assert_ne!(new_icon, derived_icon);
|
|
let img = image::load_from_memory(&fs::read(format!("{}/icon.png", character_dir(character_id))).unwrap()).unwrap();
|
|
assert_eq!((img.width(), img.height()), (230, 230));
|
|
|
|
// A card can reference the uploader's own character; the character
|
|
// then can't be deleted until the card goes
|
|
let mut fields = base_fields();
|
|
field(&mut fields, "master_character_id", &character_id.to_string());
|
|
let card_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_card(4002, &fields).unwrap());
|
|
let err = with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_character(4002, character_id)).unwrap_err();
|
|
assert!(err.contains("still use this character"), "{}", err);
|
|
with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_card(4002, card_id).unwrap());
|
|
with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_character(4002, character_id).unwrap());
|
|
assert!(!database::has_character(character_id));
|
|
assert!(fs::read_dir(character_dir(character_id)).is_err());
|
|
|
|
// Another user can't reference a draft-only character of somebody else
|
|
let other = character_fields();
|
|
let other_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_character(4002, &other).unwrap());
|
|
let mut fields = base_fields();
|
|
field(&mut fields, "master_character_id", &other_id.to_string());
|
|
let err = with_permissions(4003, &[permissions::CARD_UPLOAD], || create_card(4003, &fields)).unwrap_err();
|
|
assert!(err.contains("Unknown master_character_id"), "{}", err);
|
|
|
|
wipe(4002);
|
|
wipe(4003);
|
|
}
|
|
|
|
#[test]
|
|
fn every_validation_rejection() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4004);
|
|
|
|
let run = |fields: &Fields| with_permissions(4004, &[permissions::CARD_UPLOAD], || create_card(4004, fields));
|
|
let mutated = |key: &str, value: &str| {
|
|
let mut fields = base_fields();
|
|
field(&mut fields, key, value);
|
|
fields
|
|
};
|
|
|
|
// Referential integrity
|
|
assert!(run(&mutated("master_character_id", "999999")).unwrap_err().contains("Unknown master_character_id"));
|
|
assert!(run(&mutated("master_character_id", "0")).unwrap_err().contains("Unknown master_character_id"));
|
|
assert!(run(&mutated("master_skill_center_id", "0")).unwrap_err().contains("master_skill_center_id"));
|
|
assert!(run(&mutated("master_skill_center_id", "424242")).unwrap_err().contains("master_skill_center_id"));
|
|
assert!(run(&mutated("name", "")).unwrap_err().contains("Card name is required"));
|
|
assert!(run(&mutated("name_en", "")).unwrap_err().contains("Card English name is required"));
|
|
assert!(run(&mutated("skill_name", "")).unwrap_err().contains("Skill name is required"));
|
|
assert!(run(&mutated("skill_detail_text_en", "")).unwrap_err().contains("Skill English description is required"));
|
|
assert!(run(&mutated("skill_target_group_id", "123")).unwrap_err().contains("skill_target_group_id"));
|
|
|
|
// Rich-text tags: every one of these strings is drawn by TMP with rich text ON and no
|
|
// escaping, so markup in a name or a caption mangles the screens it lands on
|
|
for key in ["name", "name_en", "skill_name", "skill_name_en"] {
|
|
let error = run(&mutated(key, "<size=400%>x")).unwrap_err();
|
|
assert!(error.contains("<size>"), "{} -> {}", key, error);
|
|
}
|
|
assert!(run(&mutated("skill_detail_text", "<sprite=1>")).unwrap_err().contains("<sprite>"));
|
|
// A description may wrap and "<3" is not a tag, so neither is rejected (proved without
|
|
// creating cards in rich_text's own unit tests - this test only exercises rejections)
|
|
|
|
// Enum ranges - each one is a client crash, not a cosmetic error -
|
|
// and every message states the actual allowed range
|
|
assert!(run(&mutated("type", "0")).unwrap_err().contains("type must be 1-4"));
|
|
assert!(run(&mutated("type", "5")).unwrap_err().contains("type must be 1-4"));
|
|
assert!(run(&mutated("rarity", "0")).unwrap_err().contains("rarity must be 1-3"));
|
|
assert!(run(&mutated("rarity", "4")).unwrap_err().contains("rarity must be 1-3"));
|
|
assert!(run(&mutated("skill_trigger", "5")).unwrap_err().contains("skill_trigger must be 1-4"));
|
|
assert!(run(&mutated("skill_trigger", "0")).unwrap_err().contains("skill_trigger must be 1-4"));
|
|
assert!(run(&mutated("skill_effect_type", "12")).unwrap_err().contains("skill_effect_type must be 1-11"));
|
|
assert!(run(&mutated("skill_effect_type", "0")).unwrap_err().contains("skill_effect_type must be 1-11"));
|
|
assert!(run(&mutated("skill_sub_target", "2")).unwrap_err().contains("skill_sub_target must be 0 or 1"));
|
|
assert!(run(&mutated("skill_target_school_grade", "4")).unwrap_err().contains("skill_target_school_grade must be 0-3"));
|
|
|
|
// Skill array shapes: rarity 1 needs exactly 3 level entries; a lone
|
|
// effective_milli_secs is the one shipped exception
|
|
assert!(run(&mutated("skill_effective_values", "124/126")).unwrap_err().contains("skill_effective_values"));
|
|
assert!(run(&mutated("skill_probability", "1/2/3/4")).unwrap_err().contains("skill_probability"));
|
|
assert!(run(&mutated("skill_effective_values", "1/2/-3")).unwrap_err().contains("uint"));
|
|
assert!(run(&mutated("skill_effective_values", "1/2/x")).unwrap_err().contains("not a number"));
|
|
assert!(run(&mutated("skill_trigger_value", "")).unwrap_err().contains("skill_trigger_value"));
|
|
assert!(run(&mutated("skill_effective_milli_secs", "2000")).is_ok());
|
|
// Rarity 2 wants 5 entries, so the rarity-1 arrays no longer fit
|
|
assert!(run(&mutated("rarity", "2")).unwrap_err().contains("needs exactly 5"));
|
|
|
|
// Skill MAGNITUDES clamp to the shipped ranges - both real incidents:
|
|
// the 40-second buff and the 1e8 score-up
|
|
let ms_err = run(&mutated("skill_effective_milli_secs", "40000/40000/40000")).unwrap_err();
|
|
assert!(ms_err.contains("skill_effective_milli_secs") && ms_err.contains("2000-6000"), "{}", ms_err);
|
|
let value_err = run(&mutated("skill_effective_values", "100000000/100000000/100000000")).unwrap_err();
|
|
assert!(value_err.contains("91-439") && value_err.contains("score up") && value_err.contains("official"), "{}", value_err);
|
|
// Literal e-notation never even parses as an integer
|
|
assert!(run(&mutated("skill_effective_values", "1e8/1e8/1e8")).unwrap_err().contains("not a number"));
|
|
// Boundary accept/reject for effect 4 (score up), official 91-439
|
|
assert!(run(&mutated("skill_effective_values", "91/200/439")).is_ok());
|
|
assert!(run(&mutated("skill_effective_values", "91/200/440")).unwrap_err().contains("out of range"));
|
|
assert!(run(&mutated("skill_effective_values", "90/200/439")).unwrap_err().contains("out of range"));
|
|
// A SIF1-port effect type bounds to the imported band's range
|
|
let mut fields = base_fields();
|
|
field(&mut fields, "skill_effect_type", "5");
|
|
field(&mut fields, "skill_effective_values", "1/2/2");
|
|
assert!(run(&fields).is_ok());
|
|
field(&mut fields, "skill_effect_type", "7");
|
|
let err = run(&fields).unwrap_err();
|
|
assert!(err.contains("1090-2600") && err.contains("imported"), "{}", err);
|
|
// Trigger values bound per trigger (trigger 3 = PERFECTs, 13-30) and
|
|
// probability to the official 16%-75%
|
|
assert!(run(&mutated("skill_trigger_value", "13/20/31")).unwrap_err().contains("13-30"));
|
|
assert!(run(&mutated("skill_probability", "80000/39000/39000")).unwrap_err().contains("16000-75000"));
|
|
assert!(run(&mutated("skill_probability", "15999/39000/39000")).unwrap_err().contains("out of range"));
|
|
|
|
// Stat bounds, computed from the official card.csv: hp really is a
|
|
// tiny per-rarity constant (R 2 / SR 3 / UR 4), so an R card allows
|
|
// 1-2 - and the message says so instead of hiding the limit
|
|
let caps = STAT_CAPS.get(&1).copied().unwrap();
|
|
assert_eq!(caps.0, 2, "official R hp cap");
|
|
assert!(caps.1 > 0);
|
|
let smile_err = run(&mutated("smile", &(caps.1 + 1).to_string())).unwrap_err();
|
|
assert!(smile_err.contains(&format!("smile must be between 1 and {} for a R card", caps.1)), "{}", smile_err);
|
|
let hp_err = run(&mutated("hp", "3")).unwrap_err();
|
|
assert!(hp_err.contains("hp must be between 1 and 2 for a R card"), "{}", hp_err);
|
|
assert!(run(&mutated("hp", "0")).unwrap_err().contains("hp must be between 1 and 2"));
|
|
assert!(run(&mutated("hp", "-1")).unwrap_err().contains("hp must be between 1 and 2"));
|
|
|
|
// Fail fast: a field error rejects BEFORE any art is decoded - the
|
|
// garbage art bytes never get the chance to produce their own error
|
|
let mut fields = base_fields();
|
|
field(&mut fields, "rarity", "9");
|
|
fields.insert(String::from("art_00"), b"garbage that would fail decoding".to_vec());
|
|
assert!(run(&fields).unwrap_err().contains("rarity must be 1-3"));
|
|
|
|
// Art: both source artworks are required on create; garbage and
|
|
// absurdly small inputs are refused with clear errors
|
|
let mut fields = base_fields();
|
|
fields.remove("art_01");
|
|
assert!(run(&fields).unwrap_err().contains("'art_01' (evolved card artwork) is required"));
|
|
let mut fields = base_fields();
|
|
fields.insert(String::from("art_00"), b"not an image at all".to_vec());
|
|
assert!(run(&fields).unwrap_err().contains("not a decodable image"));
|
|
let mut fields = base_fields();
|
|
fields.insert(String::from("art_00"), seeded_png(32, 32, 7));
|
|
assert!(run(&fields).unwrap_err().contains("at least"));
|
|
// The per-file cap rejects before the decoder ever sees the bytes
|
|
let mut fields = base_fields();
|
|
fields.insert(String::from("art_00"), vec![0u8; MAX_FILE_BYTES + 1]);
|
|
assert!(run(&fields).unwrap_err().contains("per-file limit"));
|
|
|
|
// A wrong-sized per-kind override is cropped to target, never rejected
|
|
let mut fields = base_fields();
|
|
fields.insert(String::from("sc_00"), seeded_png(640, 640, 8));
|
|
let override_id = run(&fields).unwrap();
|
|
let sc = image::load_from_memory(&fs::read(format!("{}/sc_00.png", card_dir(override_id))).unwrap()).unwrap();
|
|
assert_eq!((sc.width(), sc.height()), (1024, 512));
|
|
|
|
// Character validation via the character route
|
|
let runc = |fields: &Fields| with_permissions(4004, &[permissions::CARD_UPLOAD], || create_character(4004, fields));
|
|
let mut fields = character_fields();
|
|
fields.remove("sign");
|
|
assert!(runc(&fields).unwrap_err().contains("'sign' art is required"));
|
|
let mut fields = character_fields();
|
|
fields.remove("character");
|
|
assert!(runc(&fields).unwrap_err().contains("'character' art is required"));
|
|
let mut fields = character_fields();
|
|
field(&mut fields, "character_name_ruby_en", "");
|
|
assert!(runc(&fields).unwrap_err().contains("English name reading is required"));
|
|
let mut fields = character_fields();
|
|
field(&mut fields, "character_image_color", "red");
|
|
assert!(runc(&fields).unwrap_err().contains("hex color"));
|
|
let mut fields = character_fields();
|
|
fields.insert(String::from("pr"), seeded_png(32, 32, 9));
|
|
assert!(runc(&fields).unwrap_err().contains("at least"));
|
|
|
|
// Only the deliberate successes above wrote rows
|
|
assert_eq!(database::card_count_for_owner(4004), 4);
|
|
wipe(4004);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_gates() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4005);
|
|
wipe(4006);
|
|
|
|
let fields = base_fields();
|
|
// No scopes at all
|
|
assert!(create_card(4005, &fields).unwrap_err().contains("permission to upload"));
|
|
assert!(create_character(4005, &character_fields()).unwrap_err().contains("permission to upload"));
|
|
|
|
let id = with_permissions(4005, &[permissions::CARD_UPLOAD], || create_card(4005, &fields).unwrap());
|
|
|
|
// card.upload edits/deletes its OWN cards but cannot publish
|
|
assert!(with_permissions(4005, &[permissions::CARD_UPLOAD], || set_card_flags(4005, id, Some(true), None)).unwrap_err().contains("permission to publish"));
|
|
let mut published_fields = base_fields();
|
|
field(&mut published_fields, "published", "1");
|
|
assert!(with_permissions(4005, &[permissions::CARD_UPLOAD], || create_card(4005, &published_fields)).unwrap_err().contains("permission to publish"));
|
|
let published_id = with_permissions(4005, &[permissions::CARD_UPLOAD, permissions::CARD_PUBLISH], || create_card(4005, &published_fields).unwrap());
|
|
assert!(database::is_published(published_id));
|
|
|
|
// A stranger with card.upload/card.publish can't touch someone else's
|
|
let mut edit = Fields::new();
|
|
field(&mut edit, "name", "Hijacked");
|
|
assert!(with_permissions(4006, &[permissions::CARD_UPLOAD], || update_card(4006, id, &edit)).unwrap_err().contains("permission to edit"));
|
|
assert!(with_permissions(4006, &[permissions::CARD_UPLOAD], || delete_card(4006, id)).unwrap_err().contains("permission to delete"));
|
|
assert!(with_permissions(4006, &[permissions::CARD_PUBLISH], || set_card_flags(4006, id, Some(true), None)).unwrap_err().contains("permission to publish"));
|
|
|
|
// card.edit is moderation: manage ANY card
|
|
with_permissions(4006, &[permissions::CARD_EDIT], || {
|
|
update_card(4006, id, &edit).unwrap();
|
|
set_card_flags(4006, id, Some(true), Some(true)).unwrap();
|
|
set_card_flags(4006, id, Some(false), Some(false)).unwrap();
|
|
});
|
|
assert_eq!(database::get_card(id).unwrap()["name"].to_string(), "Hijacked");
|
|
with_permissions(4006, &[permissions::CARD_EDIT], || delete_card(4006, id).unwrap());
|
|
assert!(database::get_card(id).is_none());
|
|
|
|
wipe(4005);
|
|
wipe(4006);
|
|
}
|
|
|
|
// Present fields replace, absent fields keep, the id never moves, and a
|
|
// replaced art file self-heals its md5
|
|
#[test]
|
|
fn update_edits_in_place() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
wipe(4007);
|
|
|
|
let fields = base_fields();
|
|
let id = with_permissions(4007, &[permissions::CARD_UPLOAD], || create_card(4007, &fields).unwrap());
|
|
let before = database::get_card(id).unwrap();
|
|
|
|
// The sc_00 override is deliberately the wrong size: an update crops
|
|
// it to target just like create does. The effect change brings values
|
|
// in that effect's shipped range along (the old ones are out of it)
|
|
let mut edit = Fields::new();
|
|
field(&mut edit, "name", "Renamed");
|
|
field(&mut edit, "skill_effect_type", "7");
|
|
field(&mut edit, "skill_effective_values", "1090/2000/2600");
|
|
edit.insert(String::from("sc_00"), seeded_png(700, 900, 99));
|
|
with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap());
|
|
|
|
let after = database::get_card(id).unwrap();
|
|
assert_eq!(after["master_card_id"].as_i64(), Some(id));
|
|
assert_eq!(after["name"].to_string(), String::from("Renamed"));
|
|
assert_eq!(after["skill"]["effect_type"].as_i64(), Some(7));
|
|
assert_eq!(after["skill"]["name"], before["skill"]["name"]);
|
|
assert_eq!(after["smile"], before["smile"]);
|
|
assert_eq!(after["illust_id"], before["illust_id"]);
|
|
assert_eq!(after["art"].len(), 14);
|
|
|
|
let old = before["art"].members().find(|art| art["kind"] == "sc" && art["variant"] == "00").unwrap()["md5"].to_string();
|
|
let new = after["art"].members().find(|art| art["kind"] == "sc" && art["variant"] == "00").unwrap()["md5"].to_string();
|
|
assert_ne!(old, new);
|
|
assert_eq!(database::find_asset_by_md5(&old), None);
|
|
assert_eq!(database::find_asset_by_md5(&new), Some(format!("{}/sc_00.png", id)));
|
|
let img = image::load_from_memory(&fs::read(format!("{}/sc_00.png", card_dir(id))).unwrap()).unwrap();
|
|
assert_eq!((img.width(), img.height()), (1024, 512));
|
|
|
|
// Re-supplying a source artwork re-derives its whole variant
|
|
let old_c01 = after["art"].members().find(|art| art["kind"] == "c" && art["variant"] == "01").unwrap()["md5"].to_string();
|
|
let mut edit = Fields::new();
|
|
edit.insert(String::from("art_01"), cutout_png(600, 900, 123));
|
|
with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap());
|
|
let rederived = database::get_card(id).unwrap();
|
|
assert_eq!(rederived["art"].len(), 14);
|
|
let new_c01 = rederived["art"].members().find(|art| art["kind"] == "c" && art["variant"] == "01").unwrap()["md5"].to_string();
|
|
assert_ne!(new_c01, old_c01);
|
|
|
|
// An edit that would break a range is rejected and nothing is written
|
|
let mut bad = Fields::new();
|
|
field(&mut bad, "skill_trigger", "9");
|
|
assert!(with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &bad)).is_err());
|
|
assert_eq!(database::get_card(id).unwrap()["skill"]["trigger"], after["skill"]["trigger"]);
|
|
|
|
wipe(4007);
|
|
}
|
|
|
|
// The runtime band must never reach a client that can't fetch the catalog
|
|
#[test]
|
|
fn strip_removes_unsupported_cards_and_their_references() {
|
|
let _lock = crate::runtime::lock_test_data_path();
|
|
let custom = database::FIRST_CARD_ID;
|
|
let mut user = object!{
|
|
"user": {
|
|
"favorite_master_card_id": custom,
|
|
"guest_smile_master_card_id": 10010001
|
|
},
|
|
"card_list": [
|
|
{ "master_card_id": 10010001 },
|
|
{ "master_card_id": 100010001 },
|
|
{ "master_card_id": custom }
|
|
],
|
|
"deck_list": [
|
|
{ "main_card_ids": [custom, 10010001, 0] }
|
|
]
|
|
};
|
|
strip_unsupported(&mut user);
|
|
assert_eq!(user["card_list"].len(), 2);
|
|
// The imported band stays: those rows are baked into masterdata
|
|
assert!(user["card_list"].members().any(|card| card["master_card_id"] == 100010001));
|
|
assert!(!user["card_list"].members().any(|card| card["master_card_id"] == custom));
|
|
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(10010001));
|
|
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(0));
|
|
assert_eq!(user["user"]["guest_smile_master_card_id"].as_i64(), Some(10010001));
|
|
}
|
|
|
|
#[test]
|
|
fn bands_do_not_overlap() {
|
|
assert!(is_custom_runtime(database::FIRST_CARD_ID));
|
|
assert!(!is_custom_runtime(141_720_001));
|
|
assert!(crate::router::card::is_custom(141_720_001));
|
|
assert!(!crate::router::card::is_custom(10_010_001));
|
|
assert!(viewer_can_resolve(10_010_001, 0));
|
|
assert!(!viewer_can_resolve(100_010_001, 1));
|
|
assert!(viewer_can_resolve(100_010_001, 2));
|
|
}
|
|
}
|