mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
Compare commits
2 Commits
f75891dea0
...
b4cd6968d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4cd6968d8 | ||
|
|
ee1a3d827e |
@@ -239,6 +239,24 @@ pub fn export_allowed(music_id: i64, viewer: Option<i64>) -> Result<(), &'static
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The display title for a PUBLIC custom song, for the clear-rate page.
|
||||||
|
// Private/shared songs return None - their names (and existence) must not
|
||||||
|
// leak beyond the visibility rules, so this never falls back past the
|
||||||
|
// explicit visibility check (an absent row is None, not "public")
|
||||||
|
pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||||
|
if !(FIRST_MUSIC_ID..=LAST_MUSIC_ID).contains(&music_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let visibility = DATABASE.lock_and_select("SELECT visibility FROM songs WHERE music_id=?1", params!(music_id)).ok()?;
|
||||||
|
if visibility != "public" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let song = get_song(music_id)?;
|
||||||
|
let name = song["name"].as_str().unwrap_or("").to_string();
|
||||||
|
let name_en = song["name_en"].as_str().unwrap_or("").to_string();
|
||||||
|
Some(if english && !name_en.is_empty() { name_en } else { name })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
|
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
|
||||||
DATABASE.lock_and_select_all("
|
DATABASE.lock_and_select_all("
|
||||||
SELECT music_id FROM songs
|
SELECT music_id FROM songs
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
|
|||||||
println!("Purged {} accounts", ct);
|
println!("Purged {} accounts", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-time, idempotent: charts stored before the custom-song spawn-group pairing rule are
|
||||||
|
// regrouped in place and their catalog md5s refreshed, so clients re-download the fixed
|
||||||
|
// encoding. No-op when custom songs are disabled or nothing needs rewriting.
|
||||||
|
router::custom_song::migrate::run();
|
||||||
|
|
||||||
let rv = HttpServer::new(|| App::new()
|
let rv = HttpServer::new(|| App::new()
|
||||||
//.wrap(Cors::permissive())
|
//.wrap(Cors::permissive())
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
|
|||||||
@@ -173,9 +173,22 @@ fn get_song_title(live_id: i32, english: bool) -> String {
|
|||||||
if !details.is_null() {
|
if !details.is_null() {
|
||||||
return details["name"].to_string();
|
return details["name"].to_string();
|
||||||
}
|
}
|
||||||
|
// Custom songs aren't in the official music mst (their live_id ==
|
||||||
|
// music_id). PUBLIC ones show their real title; private/shared ones are
|
||||||
|
// already filtered out of the page and would stay "Unknown Song" even if
|
||||||
|
// one slipped through - the lookup only ever answers for public songs
|
||||||
|
if let Some(title) = crate::router::custom_song::public_song_title(live_id as i64, english) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
String::from("Unknown Song")
|
String::from("Unknown Song")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Titles land inside HTML text and attributes; custom song names are user
|
||||||
|
// input, so escape them (official names contain nothing that needs it)
|
||||||
|
fn html_escape(text: &str) -> String {
|
||||||
|
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_pass_percent(failed: i64, pass: i64) -> String {
|
fn get_pass_percent(failed: i64, pass: i64) -> String {
|
||||||
let total = (failed + pass) as f64;
|
let total = (failed + pass) as f64;
|
||||||
if failed + pass == 0 {
|
if failed + pass == 0 {
|
||||||
@@ -315,8 +328,8 @@ fn get_html() -> JsonValue {
|
|||||||
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
|
if total == 0 { 0.0 } else { pass as f64 / total as f64 }
|
||||||
};
|
};
|
||||||
|
|
||||||
let title_jp = get_song_title(info.live_id, false);
|
let title_jp = html_escape(&get_song_title(info.live_id, false));
|
||||||
let title_en = get_song_title(info.live_id, true);
|
let title_en = html_escape(&get_song_title(info.live_id, true));
|
||||||
|
|
||||||
let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass);
|
let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass);
|
||||||
let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass);
|
let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass);
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ const CARD_TYPE_MAX: i64 = 4;
|
|||||||
const CARD_RARITY_MIN: i64 = 1;
|
const CARD_RARITY_MIN: i64 = 1;
|
||||||
const CARD_RARITY_MAX: i64 = 3;
|
const CARD_RARITY_MAX: i64 = 3;
|
||||||
const RARITY_NAMES: &[&str] = &["R", "SR", "UR"];
|
const RARITY_NAMES: &[&str] = &["R", "SR", "UR"];
|
||||||
// Sanity ceilings for the level-indexed skill arrays
|
// Indexed by effect_type (0 unused)
|
||||||
const SKILL_PROBABILITY_MAX: i64 = 1_000_000;
|
const EFFECT_NAMES: &[&str] = &[
|
||||||
const SKILL_MILLI_SECS_MAX: i64 = 600_000;
|
"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 {
|
struct ArtKind {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
@@ -144,6 +147,36 @@ const MAX_VOICE_SECONDS: f64 = 30.0;
|
|||||||
|
|
||||||
type Fields = HashMap<String, Vec<u8>>;
|
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! {
|
lazy_static! {
|
||||||
// Id allocation and the insert must not race between two uploads
|
// Id allocation and the insert must not race between two uploads
|
||||||
static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(());
|
static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(());
|
||||||
@@ -180,6 +213,70 @@ lazy_static! {
|
|||||||
rv
|
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
|
// rarity -> (hp, smile, cool, pure) ceilings: the maximum any official
|
||||||
// card of that rarity reaches. do_reinforce trusts the stored card
|
// card of that rarity reaches. do_reinforce trusts the stored card
|
||||||
// completely, so an uploaded stat is permanent - cap it at upload
|
// completely, so an uploaded stat is permanent - cap it at upload
|
||||||
@@ -367,6 +464,24 @@ pub fn upload_limits() -> JsonValue {
|
|||||||
"name_en": row["nameEn"].clone()
|
"name_en": row["nameEn"].clone()
|
||||||
}).unwrap();
|
}).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!{
|
object!{
|
||||||
"stat_caps": stat_caps,
|
"stat_caps": stat_caps,
|
||||||
"skill_levels": skill_levels,
|
"skill_levels": skill_levels,
|
||||||
@@ -377,8 +492,12 @@ pub fn upload_limits() -> JsonValue {
|
|||||||
"effect_type_max": SKILL_EFFECT_TYPE_MAX,
|
"effect_type_max": SKILL_EFFECT_TYPE_MAX,
|
||||||
"sub_target_max": SKILL_SUB_TARGET_MAX,
|
"sub_target_max": SKILL_SUB_TARGET_MAX,
|
||||||
"school_grade_max": SKILL_SCHOOL_GRADE_MAX,
|
"school_grade_max": SKILL_SCHOOL_GRADE_MAX,
|
||||||
"probability_max": SKILL_PROBABILITY_MAX,
|
"skill_ranges": {
|
||||||
"milli_secs_max": SKILL_MILLI_SECS_MAX,
|
"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
|
"min_source_dim": art::MIN_SOURCE_DIM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -903,22 +1022,32 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The client walks these in lockstep with the skill level, so each must
|
// 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
|
// carry exactly one value per level of the rarity's curve (shipped
|
||||||
// masterdata also allows a single constant duration, so
|
// masterdata also allows a single constant duration, so
|
||||||
// effective_milli_secs may be 1 long
|
// 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();
|
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
|
||||||
for (form, key, max) in [
|
for (form, key, min, max, label) in bounds {
|
||||||
("skill_trigger_value", "trigger_value", u32::MAX as i64),
|
|
||||||
("skill_probability", "probability", SKILL_PROBABILITY_MAX),
|
|
||||||
("skill_effective_milli_secs", "effective_milli_secs", SKILL_MILLI_SECS_MAX),
|
|
||||||
("skill_effective_values", "effective_values", u32::MAX as i64)
|
|
||||||
] {
|
|
||||||
let values = levels_of(fields, form, &stored_skill, key)?;
|
let values = levels_of(fields, form, &stored_skill, key)?;
|
||||||
if values.len() != levels && !(key == "effective_milli_secs" && values.len() == 1) {
|
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()));
|
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 > max) {
|
if let Some(value) = values.iter().find(|v| **v < min || **v > max) {
|
||||||
return Err(format!("{}: '{}' exceeds the maximum of {}", form, value, max));
|
return Err(format!("{}: '{}' is out of range - values must be {}-{} {}", form, value, min, max, label));
|
||||||
}
|
}
|
||||||
arrays.insert(key, values);
|
arrays.insert(key, values);
|
||||||
}
|
}
|
||||||
@@ -1624,6 +1753,31 @@ pub mod tests {
|
|||||||
wipe(4010);
|
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
|
// A full create: derived ids, pinned columns, art md5s and the catalog
|
||||||
// shape the client parses
|
// shape the client parses
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1806,10 +1960,35 @@ pub mod tests {
|
|||||||
assert!(run(&mutated("skill_effective_values", "1/2/x")).unwrap_err().contains("not a number"));
|
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_trigger_value", "")).unwrap_err().contains("skill_trigger_value"));
|
||||||
assert!(run(&mutated("skill_effective_milli_secs", "2000")).is_ok());
|
assert!(run(&mutated("skill_effective_milli_secs", "2000")).is_ok());
|
||||||
assert!(run(&mutated("skill_probability", "2000000/1/1")).unwrap_err().contains("maximum"));
|
|
||||||
// Rarity 2 wants 5 entries, so the rarity-1 arrays no longer fit
|
// Rarity 2 wants 5 entries, so the rarity-1 arrays no longer fit
|
||||||
assert!(run(&mutated("rarity", "2")).unwrap_err().contains("needs exactly 5"));
|
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
|
// 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
|
// 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
|
// 1-2 - and the message says so instead of hiding the limit
|
||||||
@@ -1871,8 +2050,8 @@ pub mod tests {
|
|||||||
fields.insert(String::from("pr"), seeded_png(32, 32, 9));
|
fields.insert(String::from("pr"), seeded_png(32, 32, 9));
|
||||||
assert!(runc(&fields).unwrap_err().contains("at least"));
|
assert!(runc(&fields).unwrap_err().contains("at least"));
|
||||||
|
|
||||||
// Only the two deliberate successes above wrote rows
|
// Only the deliberate successes above wrote rows
|
||||||
assert_eq!(database::card_count_for_owner(4004), 2);
|
assert_eq!(database::card_count_for_owner(4004), 4);
|
||||||
wipe(4004);
|
wipe(4004);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,10 +2109,12 @@ pub mod tests {
|
|||||||
let before = database::get_card(id).unwrap();
|
let before = database::get_card(id).unwrap();
|
||||||
|
|
||||||
// The sc_00 override is deliberately the wrong size: an update crops
|
// The sc_00 override is deliberately the wrong size: an update crops
|
||||||
// it to target just like create does
|
// 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();
|
let mut edit = Fields::new();
|
||||||
field(&mut edit, "name", "Renamed");
|
field(&mut edit, "name", "Renamed");
|
||||||
field(&mut edit, "skill_effect_type", "7");
|
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));
|
edit.insert(String::from("sc_00"), seeded_png(700, 900, 99));
|
||||||
with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap());
|
with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap());
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// in-process symphonia + vorbis machinery
|
// in-process symphonia + vorbis machinery
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
mod chart;
|
mod chart;
|
||||||
|
// One-time startup regroup of charts stored before the spawn-group pairing rule; called from
|
||||||
|
// run_server, no-op when the feature is disabled or every chart is already correctly grouped
|
||||||
|
pub mod migrate;
|
||||||
mod package;
|
mod package;
|
||||||
|
|
||||||
use jzon::{array, object, JsonValue};
|
use jzon::{array, object, JsonValue};
|
||||||
@@ -121,6 +124,15 @@ pub fn hidden_live_ids() -> JsonValue {
|
|||||||
database::non_public_music_ids()
|
database::non_public_music_ids()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The clear-rate page shows real titles for PUBLIC custom songs; anything
|
||||||
|
// else stays exactly as hidden as before
|
||||||
|
pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||||
|
if disabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
database::public_song_title(music_id, english)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
|
pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
|
||||||
if disabled() {
|
if disabled() {
|
||||||
return array![];
|
return array![];
|
||||||
@@ -1015,6 +1027,102 @@ mod tests {
|
|||||||
fields.insert(String::from(key), value.as_bytes().to_vec());
|
fields.insert(String::from(key), value.as_bytes().to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a
|
||||||
|
// full 9-lane wall (the shape of the field-reported chart that exposed the old encoding)
|
||||||
|
fn wall_chart() -> Vec<u8> {
|
||||||
|
let mut beatmap = jzon::array![];
|
||||||
|
for position in [2, 4, 6, 8] {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": 1.0, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 3, "effect_value": 1.0, "position": position
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
for position in 1..=9 {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": 2.75, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 1, "effect_value": 0.0, "position": position
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
jzon::stringify(beatmap).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup migration: a chart stored with the PRE-pairing encoding (whole equal-time
|
||||||
|
// clusters sharing one num) is regrouped in place, its catalog md5/size follow the new
|
||||||
|
// bytes, the revision bumps exactly once, correctly-encoded songs stay byte-identical,
|
||||||
|
// and a second run is a complete no-op
|
||||||
|
#[test]
|
||||||
|
fn startup_migration_regroups_pre_fix_charts() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Migration Target");
|
||||||
|
field(&mut fields, "artist", "Wall Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_4", "15");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(550.0));
|
||||||
|
fields.insert(String::from("chart_4"), wall_chart());
|
||||||
|
let target = create_song(3333, &fields).unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Migration Control");
|
||||||
|
field(&mut fields, "artist", "Control Artist");
|
||||||
|
field(&mut fields, "attribute", "2");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(770.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let control = create_song(4444, &fields).unwrap();
|
||||||
|
|
||||||
|
// The upload stored the CURRENT encoding; capture it, then doctor the store back to
|
||||||
|
// the pre-pairing form exactly as an old server would have written it: squashed
|
||||||
|
// chart bytes on disk and the catalog md5/size matching those bytes
|
||||||
|
let path = song_path(target, "chart_4.json");
|
||||||
|
let fixed_bytes = fs::read(&path).unwrap();
|
||||||
|
let mut squashed = jzon::parse(&String::from_utf8_lossy(&fixed_bytes)).unwrap();
|
||||||
|
chart::squash_to_pre_fix(&mut squashed);
|
||||||
|
let squashed_bytes = jzon::stringify(squashed).into_bytes();
|
||||||
|
assert_ne!(squashed_bytes, fixed_bytes);
|
||||||
|
fs::write(&path, &squashed_bytes).unwrap();
|
||||||
|
let mut song = database::get_song(target).unwrap();
|
||||||
|
let (md5, size) = asset_meta(&squashed_bytes);
|
||||||
|
for entry in song["levels"].members_mut() {
|
||||||
|
if entry["level"] == 4 {
|
||||||
|
entry["md5"] = md5.clone().into();
|
||||||
|
entry["size"] = size.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database::update_song(target, &song);
|
||||||
|
|
||||||
|
let control_bytes = fs::read(song_path(control, "chart_1.json")).unwrap();
|
||||||
|
let control_song = database::get_song(control).unwrap();
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
// The target chart is byte-identical to what the current transcoder stores, and the
|
||||||
|
// catalog follows the new bytes
|
||||||
|
let migrated = fs::read(&path).unwrap();
|
||||||
|
assert_eq!(migrated, fixed_bytes);
|
||||||
|
let song = database::get_song(target).unwrap();
|
||||||
|
let level = song["levels"].members().find(|l| l["level"] == 4).unwrap();
|
||||||
|
let (md5, size) = asset_meta(&fixed_bytes);
|
||||||
|
assert_eq!(level["md5"].to_string(), md5);
|
||||||
|
assert_eq!(level["size"].as_usize().unwrap(), size);
|
||||||
|
// full_combo never depended on grouping and must not move
|
||||||
|
assert_eq!(level["full_combo"], 9 + 4);
|
||||||
|
|
||||||
|
// Exactly one revision bump, and the control song is untouched
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
assert_eq!(fs::read(song_path(control, "chart_1.json")).unwrap(), control_bytes);
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(control).unwrap()), jzon::stringify(control_song));
|
||||||
|
|
||||||
|
// Idempotent: a second boot changes nothing and bumps nothing
|
||||||
|
migrate::run();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), fixed_bytes);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Export a song, import the package as another user, and the served song
|
// Export a song, import the package as another user, and the served song
|
||||||
// must be identical apart from the assigned music_id - INCLUDING the audio
|
// must be identical apart from the assigned music_id - INCLUDING the audio
|
||||||
// md5s: ogg uploads are stored as-is and the preview encode is
|
// md5s: ogg uploads are stored as-is and the preview encode is
|
||||||
@@ -1480,6 +1588,51 @@ mod tests {
|
|||||||
assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK);
|
assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The public clear-rate HTML page shows the REAL title for public custom
|
||||||
|
// songs (escaped - names are user input), keeps private songs entirely
|
||||||
|
// absent, and prefers name_en for the EN title attribute
|
||||||
|
#[test]
|
||||||
|
fn clearrate_html_shows_public_custom_song_titles() {
|
||||||
|
use actix_web::test::TestRequest;
|
||||||
|
use crate::router::clear_rate;
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let public_id = database::next_music_id();
|
||||||
|
database::insert_song(public_id, 6100, &object!{
|
||||||
|
music_id: public_id,
|
||||||
|
name: "Public <Song> & \"Co\"",
|
||||||
|
name_en: "Public Song EN"
|
||||||
|
}, "public", &array![], false);
|
||||||
|
let private_id = database::next_music_id();
|
||||||
|
database::insert_song(private_id, 6100, &object!{
|
||||||
|
music_id: private_id,
|
||||||
|
name: "Top Secret Anthem"
|
||||||
|
}, "private", &array![], false);
|
||||||
|
for id in [public_id, private_id] {
|
||||||
|
clear_rate::live_completed(id, 1, false, 100, 6100);
|
||||||
|
}
|
||||||
|
clear_rate::invalidate_cache();
|
||||||
|
|
||||||
|
let html = actix_web::rt::System::new().block_on(async {
|
||||||
|
let resp = clear_rate::clearrate_html(TestRequest::default().to_http_request()).await;
|
||||||
|
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
|
||||||
|
String::from_utf8_lossy(&body).to_string()
|
||||||
|
});
|
||||||
|
// The public song's real (escaped) title, JP cell and EN attribute
|
||||||
|
assert!(html.contains("Public <Song> & "Co""), "public title missing");
|
||||||
|
assert!(html.contains("Public Song EN"), "EN title missing");
|
||||||
|
// Never the raw unescaped markup
|
||||||
|
assert!(!html.contains("Public <Song>"), "title not escaped");
|
||||||
|
// The private song leaks neither name nor row
|
||||||
|
assert!(!html.contains("Top Secret Anthem"), "private name leaked");
|
||||||
|
|
||||||
|
// The title lookup itself only answers for public songs
|
||||||
|
assert_eq!(database::public_song_title(public_id, false), Some(String::from("Public <Song> & \"Co\"")));
|
||||||
|
assert_eq!(database::public_song_title(public_id, true), Some(String::from("Public Song EN")));
|
||||||
|
assert_eq!(database::public_song_title(private_id, false), None);
|
||||||
|
assert_eq!(database::public_song_title(private_id + 5000, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
// The JSON clear-rate endpoint filters non-public custom songs per requesting
|
// The JSON clear-rate endpoint filters non-public custom songs per requesting
|
||||||
// user: the owner sees all of theirs, a shared user sees the songs shared with
|
// user: the owner sees all of theirs, a shared user sees the songs shared with
|
||||||
// them, everyone else sees only public ones. Official (stock) live ids are
|
// them, everyone else sees only public ones. Official (stock) live ids are
|
||||||
|
|||||||
@@ -262,6 +262,113 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
}, max_combo_count))
|
}, max_combo_count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regroups a STORED transcoded chart whose spawn groups predate the pairing rule above: the
|
||||||
|
// old transcoder gave every note of an equal-time cluster one shared num (force_sync_group_id
|
||||||
|
// always 0), and the client renders no head markers for a group of 3+ (see the header
|
||||||
|
// comment). This rebuilds num / force_sync_group_id in place with the same clustering the
|
||||||
|
// transcoder now uses — equal final time, lane-sorted, chunks of two, chained
|
||||||
|
// force_sync_group_id — and re-points child_num at each child's renumbered spawn group.
|
||||||
|
// Everything else (ids, times, lines, types, parent/child links, max_combo_count — combo
|
||||||
|
// counting never depended on grouping) is untouched, so on a chart the current transcoder
|
||||||
|
// produced this reproduces the stored bytes exactly.
|
||||||
|
//
|
||||||
|
// Returns false (chart untouched) unless some num is shared by MORE than two notes. That
|
||||||
|
// makes it a safe no-op on current uploads AND on official-style encodings (whose num values
|
||||||
|
// differ from ours — e.g. gaps of 3 — but whose groups never exceed two).
|
||||||
|
pub fn regroup(chart: &mut JsonValue) -> bool {
|
||||||
|
// (id, time, line) per real note; the dummy header at [0] stays untouched
|
||||||
|
let notes: Vec<(i64, f64, i64)> = chart["notes"].members().skip(1).map(|n| (
|
||||||
|
n["id"].as_i64().unwrap_or(0),
|
||||||
|
n["time"].as_f64().unwrap_or(0.0),
|
||||||
|
n["line"].as_i64().unwrap_or(0)
|
||||||
|
)).collect();
|
||||||
|
|
||||||
|
// Only a pre-pairing chart (some num shared 3+ ways) is rewritten
|
||||||
|
let mut group_sizes: Vec<(i64, i64)> = Vec::new();
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let num = data["num"].as_i64().unwrap_or(0);
|
||||||
|
match group_sizes.iter_mut().find(|(n, _)| *n == num) {
|
||||||
|
Some((_, count)) => *count += 1,
|
||||||
|
None => group_sizes.push((num, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if group_sizes.iter().all(|(_, count)| *count <= 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time order; ids break ties (transcode issues them in time order, so this reproduces
|
||||||
|
// the emission order the grouping pass originally saw)
|
||||||
|
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||||
|
order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0)));
|
||||||
|
|
||||||
|
// id -> (new num, new force_sync_group_id)
|
||||||
|
let mut assigned: Vec<(i64, i64, i64)> = Vec::with_capacity(notes.len());
|
||||||
|
let mut num = 100;
|
||||||
|
let mut start = 0;
|
||||||
|
while start < order.len() {
|
||||||
|
let mut end = start + 1;
|
||||||
|
while end < order.len() && notes[order[end]].1 == notes[order[start]].1 {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
||||||
|
cluster.sort_by_key(|index| notes[*index].2);
|
||||||
|
let mut prev_num = 0;
|
||||||
|
for chunk in cluster.chunks(2) {
|
||||||
|
num += 1;
|
||||||
|
for index in chunk {
|
||||||
|
assigned.push((notes[*index].0, num, prev_num));
|
||||||
|
}
|
||||||
|
prev_num = num;
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
let lookup = |id: i64| assigned.iter().find(|(i, _, _)| *i == id).map(|(_, n, f)| (*n, *f));
|
||||||
|
|
||||||
|
for data in chart["notes"].members_mut().skip(1) {
|
||||||
|
let Some((new_num, force)) = lookup(data["id"].as_i64().unwrap_or(0)) else { continue; };
|
||||||
|
data["num"] = new_num.into();
|
||||||
|
data["force_sync_group_id"] = force.into();
|
||||||
|
let child = data["child_id"].as_i64().unwrap_or(0);
|
||||||
|
if child != 0 {
|
||||||
|
// child_num names the child's spawn group and must follow its new num
|
||||||
|
data["child_num"] = lookup(child).map(|(n, _)| n).unwrap_or(0).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helper: fabricates what pre-pairing servers stored, by squashing a current chart back
|
||||||
|
// to the OLD encoding — one shared num per equal-time cluster, force_sync_group_id 0, and
|
||||||
|
// child_num following. Lives outside the tests module so the migration tests in
|
||||||
|
// router/custom_song.rs can build realistic pre-fix fixtures from transcode output.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn squash_to_pre_fix(chart: &mut JsonValue) {
|
||||||
|
let notes: Vec<(i64, f64)> = chart["notes"].members().skip(1)
|
||||||
|
.map(|n| (n["id"].as_i64().unwrap(), n["time"].as_f64().unwrap()))
|
||||||
|
.collect();
|
||||||
|
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||||
|
order.sort_by(|a, b| notes[*a].1.total_cmp(¬es[*b].1).then(notes[*a].0.cmp(¬es[*b].0)));
|
||||||
|
let mut nums: Vec<(i64, i64)> = Vec::new();
|
||||||
|
let mut num = 100;
|
||||||
|
let mut last_time = f64::NEG_INFINITY;
|
||||||
|
for index in order {
|
||||||
|
if notes[index].1 != last_time {
|
||||||
|
num += 1;
|
||||||
|
last_time = notes[index].1;
|
||||||
|
}
|
||||||
|
nums.push((notes[index].0, num));
|
||||||
|
}
|
||||||
|
let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0);
|
||||||
|
for data in chart["notes"].members_mut().skip(1) {
|
||||||
|
data["num"] = lookup(data["id"].as_i64().unwrap()).into();
|
||||||
|
data["force_sync_group_id"] = 0.into();
|
||||||
|
let child = data["child_id"].as_i64().unwrap_or(0);
|
||||||
|
if child != 0 {
|
||||||
|
data["child_num"] = lookup(child).into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -657,6 +764,94 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing
|
||||||
|
// transcode output reproduces the old encoding exactly (one num per cluster, no force
|
||||||
|
// links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included.
|
||||||
|
#[test]
|
||||||
|
fn regroup_restores_pre_fix_wall_and_parallel_holds() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0),
|
||||||
|
sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0),
|
||||||
|
sif_note(2.75, 1, 1, 0.0), sif_note(2.75, 2, 1, 0.0), sif_note(2.75, 3, 1, 0.0),
|
||||||
|
sif_note(2.75, 4, 1, 0.0), sif_note(2.75, 5, 1, 0.0), sif_note(2.75, 6, 1, 0.0),
|
||||||
|
sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0),
|
||||||
|
sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0)
|
||||||
|
];
|
||||||
|
let (expected, _) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
let mut chart = expected.clone();
|
||||||
|
squash_to_pre_fix(&mut chart);
|
||||||
|
// Sanity: the squash really is the old encoding — whole clusters share one num
|
||||||
|
assert_eq!(chart["notes"][1]["num"], 101);
|
||||||
|
assert_eq!(chart["notes"][4]["num"], 101); // all 4 hold heads
|
||||||
|
assert_eq!(chart["notes"][9]["num"], 103);
|
||||||
|
assert_eq!(chart["notes"][17]["num"], 103); // all 9 wall notes
|
||||||
|
assert_eq!(chart["notes"][1]["child_num"], 102, "squashed child_num must follow");
|
||||||
|
for data in chart["notes"].members() {
|
||||||
|
assert_eq!(data["force_sync_group_id"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(regroup(&mut chart), "a squashed chart must be rewritten");
|
||||||
|
assert_eq!(jzon::stringify(chart.clone()), jzon::stringify(expected.clone()),
|
||||||
|
"regroup must reproduce the current transcoder's output exactly");
|
||||||
|
|
||||||
|
// Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the
|
||||||
|
// previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111)
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
let wall: Vec<(i64, i64, i64)> = chart["notes"].members()
|
||||||
|
.filter(|d| d["time"].as_f64() == Some(2.75))
|
||||||
|
.map(|d| (d["line"].as_i64().unwrap(), d["num"].as_i64().unwrap(), d["force_sync_group_id"].as_i64().unwrap()))
|
||||||
|
.collect();
|
||||||
|
let mut wall_sorted = wall.clone();
|
||||||
|
wall_sorted.sort();
|
||||||
|
assert_eq!(wall_sorted, vec![
|
||||||
|
(0, 105, 0), (1, 105, 0),
|
||||||
|
(2, 106, 105), (3, 106, 105),
|
||||||
|
(4, 107, 106), (5, 107, 106),
|
||||||
|
(6, 108, 107), (7, 108, 107),
|
||||||
|
(8, 109, 108)
|
||||||
|
]);
|
||||||
|
// child_num follows the child's NEW num: each head's child_num names a tail group
|
||||||
|
for head in chart["notes"].members().filter(|d| d["time"].as_f64() == Some(1.0)) {
|
||||||
|
let child_id = head["child_id"].as_i64().unwrap();
|
||||||
|
let tail = chart["notes"].members().find(|d| d["id"].as_i64() == Some(child_id)).unwrap();
|
||||||
|
assert_eq!(head["child_num"], tail["num"].clone());
|
||||||
|
assert!([103, 104].contains(&tail["num"].as_i64().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regroup_is_a_no_op_on_current_encoding() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0), sif_note(1.0, 8, 1, 0.0),
|
||||||
|
sif_note(2.0, 4, 3, 1.5)
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
let before = jzon::stringify(chart.clone());
|
||||||
|
let mut chart = chart;
|
||||||
|
assert!(!regroup(&mut chart));
|
||||||
|
assert_eq!(jzon::stringify(chart), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Official-shaped encodings (1132_5_Sn t=9.781: num gaps of 3, force_sync naming the other
|
||||||
|
// pair) have groups of at most two and must never be "normalized" to our num sequence
|
||||||
|
#[test]
|
||||||
|
fn regroup_is_a_no_op_on_official_shaped_charts() {
|
||||||
|
let mut chart = object!{
|
||||||
|
"max_lane": 9, "sound_name": "", "max_combo_count": 4,
|
||||||
|
"notes": [
|
||||||
|
{"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||||
|
{"id": 45, "num": 145, "line": 0, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||||
|
{"id": 46, "num": 145, "line": 1, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
|
||||||
|
{"id": 47, "num": 148, "line": 7, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145},
|
||||||
|
{"id": 48, "num": 148, "line": 8, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let before = jzon::stringify(chart.clone());
|
||||||
|
assert!(!regroup(&mut chart));
|
||||||
|
assert_eq!(jzon::stringify(chart), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_charts() {
|
fn rejects_bad_charts() {
|
||||||
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
||||||
|
|||||||
80
src/router/custom_song/migrate.rs
Normal file
80
src/router/custom_song/migrate.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::{chart, database, song_path, asset_meta, LEVEL_COUNT};
|
||||||
|
use crate::runtime::get_data_path;
|
||||||
|
|
||||||
|
// One-time, idempotent startup migration for charts transcoded before the spawn-group pairing
|
||||||
|
// rule (chart.rs header): the old transcoder gave every note of an equal-time cluster ONE
|
||||||
|
// shared num, and the client creates no head markers for a group of 3+ notes
|
||||||
|
// (LiveMarkerControl.CreateMarkerUI plain-returns on count > 2), so 3+ simultaneous notes
|
||||||
|
// were judged but never rendered. Stored transcoded charts are derived data with everything
|
||||||
|
// the regroup needs (time + line per note), so they are rewritten in place — no original
|
||||||
|
// upload required, which also covers songs from before export support.
|
||||||
|
//
|
||||||
|
// For each song directory with a catalog row, every chart with an over-shared num is
|
||||||
|
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
|
||||||
|
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
|
||||||
|
// re-download the fixed chart on their next catalog sync. The revision is bumped ONCE if
|
||||||
|
// anything changed. Charts the current transcoder produced (and official-shaped encodings)
|
||||||
|
// are left byte-identical, so re-running every boot is free.
|
||||||
|
pub fn run() {
|
||||||
|
if super::disabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entries) = fs::read_dir(get_data_path("custom_songs")) else {
|
||||||
|
// No custom_songs directory yet - nothing was ever uploaded
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut music_ids: Vec<i64> = entries.flatten()
|
||||||
|
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<i64>().ok())
|
||||||
|
.collect();
|
||||||
|
music_ids.sort();
|
||||||
|
|
||||||
|
let mut songs_changed = 0;
|
||||||
|
let mut charts_changed = 0;
|
||||||
|
for music_id in music_ids {
|
||||||
|
// A song directory without a catalog row is never served; leave it alone
|
||||||
|
let Some(mut song) = database::get_song(music_id) else { continue; };
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
for level in 1..=LEVEL_COUNT {
|
||||||
|
let path = song_path(music_id, &format!("chart_{}.json", level));
|
||||||
|
let Ok(bytes) = fs::read(&path) else { continue; };
|
||||||
|
let Ok(mut chart_data) = jzon::parse(&String::from_utf8_lossy(&bytes)) else {
|
||||||
|
println!("Custom song {} chart {}: not valid JSON, migration skipped", music_id, level);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !chart::regroup(&mut chart_data) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_bytes = jzon::stringify(chart_data);
|
||||||
|
if let Err(e) = fs::write(&path, &new_bytes) {
|
||||||
|
println!("Custom song {} chart {}: rewrite failed ({}), migration skipped", music_id, level, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The catalog md5/size must follow the served bytes or the client's
|
||||||
|
// download-and-verify loop would never accept the asset
|
||||||
|
let (md5, size) = asset_meta(new_bytes.as_bytes());
|
||||||
|
for entry in song["levels"].members_mut() {
|
||||||
|
if entry["level"] == level {
|
||||||
|
entry["md5"] = md5.clone().into();
|
||||||
|
entry["size"] = size.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
charts_changed += 1;
|
||||||
|
println!("Custom song {}: regrouped chart level {} (pre-pairing spawn groups)", music_id, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
database::update_song(music_id, &song);
|
||||||
|
songs_changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if songs_changed > 0 {
|
||||||
|
database::bump_revision();
|
||||||
|
println!("Custom song spawn-group migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
webui
2
webui
Submodule webui updated: 28b1c65fc6...124065a313
Reference in New Issue
Block a user