From 844a8f381bc2b87c2968808867abafedf59de4ef Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Wed, 16 Sep 2026 20:26:21 -0500 Subject: [PATCH] Add ability to nerf custom cards --- docker/docker-compose.yml | 1 + docker/start.sh | 1 + src/options.rs | 3 + src/router/custom_card.rs | 202 +++++++++++++++++++++++++++++++++++--- src/runtime.rs | 14 +++ 5 files changed, 206 insertions(+), 15 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3803a41..0956220 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,7 @@ services: DISABLE_EXPORTS: false # Will disable account exports #ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints) #ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints) + #NERF_CUSTOM_CARDS: true # Cap custom card stats below the official maxima and skills at 80% of the official range (new uploads rejected, existing cards clamped in responses); needs ENABLE_CUSTOM_CARDS #ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints) #OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards #PURGE: false # Purge dead user accounts on startup diff --git a/docker/start.sh b/docker/start.sh index ad1d3a9..4036108 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -15,6 +15,7 @@ args=( [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) +[ "${NERF_CUSTOM_CARDS:-}" = "true" ] && args+=(--nerf-custom-cards) [ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv) [ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade) diff --git a/src/options.rs b/src/options.rs index 2c179da..d857930 100644 --- a/src/options.rs +++ b/src/options.rs @@ -44,6 +44,9 @@ pub struct Args { #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] pub enable_custom_cards: bool, + #[arg(long, default_value_t = false, help = "Nerf custom cards: cap base stats below the official per-rarity maxima (median band) and skill values at 80% of the official range. Applies at upload time and to cards already uploaded, which are clamped in every response")] + pub nerf_custom_cards: bool, + #[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")] pub enable_custom_3dmv: bool, diff --git a/src/router/custom_card.rs b/src/router/custom_card.rs index 0980274..19e4735 100644 --- a/src/router/custom_card.rs +++ b/src/router/custom_card.rs @@ -386,6 +386,79 @@ lazy_static! { } rv }; + + // NERF_CUSTOM_CARDS: per-rarity (hp, smile, cool, pure) ceilings set ~200 + // below the median official card of that rarity (official medians: R ~2160, + // SR ~4010, UR ~4366 -> caps 2000 / 3800 / 4200), so a nerfed custom card + // stays under a typical official one. Skill magnitudes nerf to 4/5 of the + // official ranges + static ref NERF_STAT_CAPS: HashMap = { + let mut rv: HashMap = HashMap::new(); + for (&rarity, (hp, _, _, _)) in &*STAT_CAPS { + let cap = match rarity { + 1 => 2000, + 2 => 3800, + _ => 4200, + }; + rv.insert(rarity, (*hp, cap, cap, cap)); + } + rv + }; +} + +// The nerf band. Read from the runtime flag rather than get_args(): the +// host-config path (set_nerf_custom_cards, mirrored by overlay_args) is the +// one the lib mode and the tests reach, and get_args() chokes on the test +// harness's own CLI arguments (the same reason set_time is avoided in tests) +pub fn nerf_active() -> bool { + crate::runtime::get_nerf_custom_cards() +} + +// Skill magnitudes nerf to 4/5 of the official range +fn nerf_scale(value: i64) -> i64 { + value * 4 / 5 +} + +// A stored skill array element above the cap moves down to the cap; below it +// is untouched +fn nerf_clamp_element(value: &mut JsonValue, cap: i64) { + if cap > 0 && value.as_i64().unwrap_or(0) > cap { + *value = cap.into(); + } +} + +// NERF_CUSTOM_CARDS clamps a card's stored stats and skill magnitudes to the +// nerf band WITHOUT touching the db row - the db keeps the owner's values, +// every response carries the clamped ones. No-op below the caps, so an +// ordinary card passes through unchanged +fn nerf_clamp_card(card: &mut JsonValue) { + let rarity = card["rarity"].as_i64().unwrap_or(0); + if let Some((hp, smile, cool, pure)) = NERF_STAT_CAPS.get(&rarity) { + for (key, cap) in [("hp", *hp), ("smile", *smile), ("cool", *cool), ("pure", *pure)] { + nerf_clamp_element(&mut card[key], cap); + } + } + if card["skill"].is_null() { + return; + } + let effect_type = card["skill"]["effect_type"].as_i64().unwrap_or(0); + let trigger = card["skill"]["trigger"].as_i64().unwrap_or(0); + let (_, value_max, _) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(0, 0, "official")); + let (_, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(0, 0)); + let ((_, prob_max), (_, ms_max)) = *SKILL_SCALAR_RANGES; + for (key, cap) in [ + ("trigger_value", nerf_scale(trigger_max)), + ("probability", nerf_scale(prob_max)), + ("effective_milli_secs", nerf_scale(ms_max)), + ("effective_values", nerf_scale(value_max)) + ] { + if !card["skill"][key].is_array() { + continue; + } + for value in card["skill"][key].members_mut() { + nerf_clamp_element(value, cap); + } + } } // Game endpoints (/api scope, standard envelope) @@ -525,10 +598,15 @@ pub fn strip_unsupported(user: &mut JsonValue) { // The concrete upload bounds, served to the webui so the form can enforce // them client-side (sliders/radios) and reject out-of-range values instantly pub fn upload_limits() -> JsonValue { + let nerfed = nerf_active(); let mut stat_caps = object!{}; let mut skill_levels = object!{}; for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX { - let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); + let caps = if nerfed { + NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)) + } else { + STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)) + }; stat_caps[rarity.to_string()] = object!{ "hp": caps.0, "smile": caps.1, @@ -553,11 +631,12 @@ pub fn upload_limits() -> JsonValue { } // The skill magnitude envelopes, so the form's number inputs carry real // min/max and reject out-of-range (and e-notation) before submitting + let scale = |v: i64| if nerfed { nerf_scale(v) } else { v }; let mut effect_value_ranges = object!{}; for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() { effect_value_ranges[effect.to_string()] = object!{ "min": *min, - "max": *max, + "max": scale(*max), "source": *source }; } @@ -565,10 +644,12 @@ pub fn upload_limits() -> JsonValue { for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() { trigger_value_ranges[trigger.to_string()] = object!{ "min": *min, - "max": *max + "max": scale(*max) }; } let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES; + let prob_max = scale(prob_max); + let ms_max = scale(ms_max); // The voiceline moments, so the form renders exactly the kinds this build // accepts instead of carrying its own copy of the list let mut voice_kinds = array![]; @@ -626,7 +707,12 @@ async fn list(Login(key): Login) -> impl Responder { } 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 mut cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user)); + if nerf_active() { + for card in cards.members_mut() { + nerf_clamp_card(card); + } + } let characters = database::get_characters_for_cards(uid, &cards); Api(Some(object!{ "revision": database::get_revision(), @@ -1121,13 +1207,21 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st // Note the official HP scale before assuming a bug report: hp is a tiny // per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4) - let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); + // NERF_CUSTOM_CARDS drops the ceiling from the official rarity max to the + // nerf band (~200 below the official median) for this upload + let caps = if nerf_active() { + NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)) + } else { + STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)) + }; + let nerfed = nerf_active(); let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)]; let mut values = Vec::new(); for (key, cap) in stats { let value = number_of(fields, key, stored, key); if !(1..=cap).contains(&value) { - return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name)); + let range = if nerfed { "nerfed" } else { "official" }; + return Err(format!("{} must be between 1 and {} for a {} card (the {} {} range)", key, cap, rarity_name, range, rarity_name)); } values.push(value); } @@ -1177,17 +1271,22 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st // effective_milli_secs may be 1 long). MAGNITUDES are clamped to the // ranges the shipped skill rows actually use - a 40-second buff and a // 1e8 score-up both made it through before these bounds existed + // NERF_CUSTOM_CARDS drops the magnitude ceilings to 4/5 of the official + // range; the floor stays the official one so a nerfed card can never be + // weaker than the weakest official row of that effect + let nerfed = nerf_active(); let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1)); let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official")); let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES; + let range_word = if nerfed { "nerfed" } else { "official" }; let bounds = [ - ("skill_trigger_value", "trigger_value", trigger_min, trigger_max, - format!("for trigger {} (the official range)", trigger)), - ("skill_probability", "probability", prob_min, prob_max, - format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)), - ("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max, - String::from("milliseconds (the official range)")), - ("skill_effective_values", "effective_values", value_min, value_max, + ("skill_trigger_value", "trigger_value", trigger_min, if nerfed { nerf_scale(trigger_max) } else { trigger_max }, + format!("for trigger {} (the {} range)", trigger, range_word)), + ("skill_probability", "probability", prob_min, if nerfed { nerf_scale(prob_max) } else { prob_max }, + format!("({}%-{}%, the {} range)", prob_min / 1000, (if nerfed { nerf_scale(prob_max) } else { prob_max }) / 1000, range_word)), + ("skill_effective_milli_secs", "effective_milli_secs", ms_min, if nerfed { nerf_scale(ms_max) } else { ms_max }, + format!("milliseconds (the {} range)", range_word)), + ("skill_effective_values", "effective_values", value_min, if nerfed { nerf_scale(value_max) } else { value_max }, format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source)) ]; let mut arrays: HashMap<&str, Vec> = HashMap::new(); @@ -1389,7 +1488,13 @@ pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(), if !can_manage(uid, owner) { return Err(String::from("You do not have permission to edit this card")); } - let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?; + let mut stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?; + // An edit's "unchanged" fallbacks read from the stored blob, so the + // stored reference itself is brought into the nerf band first - an + // over-cap value the form leaves untouched stays over cap in the dry-run + if nerf_active() { + nerf_clamp_card(&mut stored); + } let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0); // Field validation first (cheap), art processing second - fail fast @@ -1726,9 +1831,15 @@ async fn mine(req: HttpRequest) -> HttpResponse { let Some(uid) = get_session_uid(&req) else { return webui::error("Not logged in"); }; + let mut cards = database::get_cards_by_owner(uid); + if nerf_active() { + for card in cards.members_mut() { + nerf_clamp_card(card); + } + } send_json(object!{ result: "OK", - cards: database::get_cards_by_owner(uid), + cards: cards, characters: database::get_characters_by_owner(uid) }) } @@ -1744,6 +1855,9 @@ async fn browse(_req: HttpRequest) -> HttpResponse { for card in cards.members_mut() { card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone(); card.remove("owner_id"); + if nerf_active() { + nerf_clamp_card(card); + } } let characters = database::get_characters_for_cards(0, &cards); send_json(object!{ @@ -2486,6 +2600,64 @@ pub mod tests { wipe(4004); } + // NERF_CUSTOM_CARDS: an over-cap upload is rejected, an over-cap value the + // owner already stored is clamped in every served view (db row untouched), + // and the skill ceiling drops to 4/5 of the official range + #[test] + fn nerf_band_caps_new_and_existing_cards() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(5005); + crate::runtime::set_nerf_custom_cards(false); + + // Un-nerfed: the official rarity ceiling (4110 for R) is reachable + let fields = base_fields(); + let id = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &fields).unwrap()); + let mut over = base_fields(); + field(&mut over, "smile", "4110"); + with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &over).unwrap()); + assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110)); + + // Nerfed: a new 4110 upload is refused at the nerf band (2000 for R)... + crate::runtime::set_nerf_custom_cards(true); + let mut high = base_fields(); + field(&mut high, "smile", "4110"); + let err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &high)).unwrap_err(); + assert!(err.contains("smile must be between 1 and 2000 for a R card") && err.contains("nerfed"), "{}", err); + // Boundary: 2000 accepted, 2001 refused. The fixture's trigger values + // (25/24/24) sit in the official 13-30 but above the nerfed 13-24, so + // a nerfed-boundary upload carries a trigger value legal in both bands + let mut at_cap = base_fields(); + field(&mut at_cap, "smile", "2000"); + field(&mut at_cap, "skill_trigger_value", "20/20/20"); + let at_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &at_cap)); + assert!(at_cap_err.is_ok(), "2000 should be accepted: {:?}", at_cap_err.err()); + let mut over_cap = base_fields(); + field(&mut over_cap, "smile", "2001"); + let over_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &over_cap)).unwrap_err(); + assert!(over_cap_err.contains("must be between 1 and 2000"), "{}", over_cap_err); + + // ...and the ALREADY-stored 4110 is clamped to the cap in the served view + let mut view = database::get_card(id).unwrap(); + nerf_clamp_card(&mut view); + assert_eq!(view["smile"].as_i64(), Some(2000)); + // while the db keeps the owner's stored value + assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110)); + + // Skill magnitudes: the ceiling is 4/5 of the official max (439 -> 351) + crate::runtime::set_nerf_custom_cards(false); + let mut strong = base_fields(); + field(&mut strong, "skill_effective_values", "439/439/439"); + field(&mut strong, "skill_trigger_value", "20/20/20"); + with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong).unwrap()); + crate::runtime::set_nerf_custom_cards(true); + let skill_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong)).unwrap_err(); + // 439 * 4/5 = 351: the official 91-439 range is capped at 351 + assert!(skill_err.contains("91-351"), "{}", skill_err); + crate::runtime::set_nerf_custom_cards(false); + + wipe(5005); + } + #[test] fn permission_gates() { let _lock = crate::runtime::lock_test_data_path(); diff --git a/src/runtime.rs b/src/runtime.rs index 3e40f9f..56f0005 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -27,6 +27,7 @@ pub struct HostConfig { pub enable_custom_cards: bool, pub enable_custom_3dmv: bool, pub enable_arcade: bool, + pub nerf_custom_cards: bool, } // Lets an embedding app (or the tests) enable the opt-in custom songs feature @@ -47,6 +48,16 @@ pub fn set_enable_arcade(enabled: bool) { HOST_CONFIG.write().unwrap().enable_arcade = enabled; } +// The custom-card nerf band. Meaningful only with enable_custom_cards; kept +// separate so an embedding app can flip them independently +pub fn set_nerf_custom_cards(enabled: bool) { + HOST_CONFIG.write().unwrap().nerf_custom_cards = enabled; +} + +pub fn get_nerf_custom_cards() -> bool { + HOST_CONFIG.read().unwrap().nerf_custom_cards +} + // The --owner uids: the permission system's bootstrap grantors. Process-level // state rather than db rows so they work on a fresh install and can't be // revoked through the webui @@ -186,6 +197,9 @@ pub fn overlay_args(args: &mut crate::options::Args) { if cfg.enable_arcade { args.enable_arcade = true; } + if cfg.nerf_custom_cards { + args.nerf_custom_cards = true; + } } // idk why an ai put tests here but they are here now. Yay tests????