diff --git a/Cargo.lock b/Cargo.lock index de8f37f..d7eb99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,7 +457,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ikamaki" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8422213..d2931cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ikamaki" -version = "0.1.0" +version = "0.2.0" edition = "2024" [dependencies] diff --git a/src/generator.rs b/src/generator.rs index 39e0a93..e8d02c1 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,6 +1,7 @@ +use crate::schedule::{Rotation, Schedule}; use rand::Rng; use rand::seq::SliceRandom; -use crate::schedule::{Rotation, Schedule}; +use std::collections::HashMap; // Splatoon Map IDs const MAP_IDS: &[i32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; @@ -11,8 +12,19 @@ const RULES: &[&str] = &["cVar", "cVlf", "cVgl"]; // Duration of a rotation const PHASE_DURATION: i64 = 2 * 60 * 60; -// Generates the rotation schedule -pub fn generate(count: usize, start_time: i64) -> Schedule { +#[derive(Default)] +pub struct RotationOverride { + pub regular: Option>, + pub ranked: Option>, + pub rule: Option, +} + +// Generates the rotation schedule +pub fn generate( + count: usize, + start_time: i64, + overrides: &HashMap, +) -> Schedule { let mut rng = rand::rng(); let mut phases = Vec::with_capacity(count); @@ -23,9 +35,30 @@ pub fn generate(count: usize, start_time: i64) -> Schedule { let start = start_time + (i as i64 * PHASE_DURATION); let end = start + PHASE_DURATION; - let regular = pick_maps(&mut rng, MAP_IDS, &prev_regular); - let gachi = pick_maps(&mut rng, MAP_IDS, &prev_gachi); - let gachi_rule = RULES[i % RULES.len()].to_string(); + let override_opt = overrides.get(&(i + 1)); + + let regular = match override_opt.and_then(|o| o.regular.as_ref()) { + Some(maps) => { + let mut sorted = maps.clone(); + sorted.sort(); + sorted + } + None => pick_maps(&mut rng, MAP_IDS, &prev_regular, &[]), + }; + + let gachi = match override_opt.and_then(|o| o.ranked.as_ref()) { + Some(maps) => { + let mut sorted = maps.clone(); + sorted.sort(); + sorted + } + None => pick_maps(&mut rng, MAP_IDS, &prev_gachi, ®ular), + }; + + let gachi_rule = match override_opt.and_then(|o| o.rule.as_ref()) { + Some(rule) => rule.clone(), + None => RULES[i % RULES.len()].to_string(), + }; prev_regular = regular.clone(); prev_gachi = gachi.clone(); @@ -44,19 +77,28 @@ pub fn generate(count: usize, start_time: i64) -> Schedule { } // Pick two maps from the pool, avoid any maps present in the previous rotation -fn pick_maps(rng: &mut R, pool: &[i32], prev: &[i32]) -> Vec { - let filtered: Vec = pool +fn pick_maps(rng: &mut R, pool: &[i32], prev: &[i32], exclude: &[i32]) -> Vec { + let hard_filtered: Vec = pool + .iter() + .filter(|&&m| !exclude.contains(&m)) + .copied() + .collect(); + + let soft_filtered: Vec = hard_filtered .iter() .filter(|&&m| !prev.contains(&m)) .copied() .collect(); - let source = if filtered.len() >= 2 { - filtered + + let source = if soft_filtered.len() >= 2 { + soft_filtered } else { - pool.to_vec() + hard_filtered }; let mut shuffled = source; shuffled.shuffle(rng); - shuffled.into_iter().take(2).collect() + let mut picked: Vec = shuffled.into_iter().take(2).collect(); + picked.sort(); + picked } diff --git a/src/main.rs b/src/main.rs index 8a53e1e..81a073e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,15 @@ use chrono::Utc; use clap::Parser; +use std::collections::HashMap; use std::path::Path; +use std::process::exit; mod exporter; mod generator; mod schedule; +use generator::RotationOverride; + // 3 weeks by default const DEFAULT: usize = 21 * 24 / 2; @@ -16,35 +20,187 @@ pub fn default_timestamp() -> i64 { now - (now % block) } +const OVERRIDE_HELP: &str = concat!( + "Override flags (stateful, order-dependent):\n", + " --interval Set the 1-based rotation index for subsequent flags\n", + " --regular Set RegularStages (exactly 2 IDs)\n", + " --ranked Set GachiStages (exactly 2 IDs)\n", + " --rule Set GachiRule (cVar, cVgl, or cVlf)\n", + "\n", + " --regular, --ranked & --rule must follow an --interval.\n", + " Missing properties for an interval are filled by the standard RNG logic.\n", + " Both --flag value and --flag=value syntaxes are accepted.\n", + "\n", + "Example:\n", + " ikamaki --yml --count 10 --interval 4 --regular \"1,0\" --ranked \\\n", + " \"6,12\" --rule \"cVar\" --interval 6 --regular \"10,13\"", +); + #[derive(Parser, Debug)] -#[command(name = "ikamaki")] +#[command(name = "ikamaki", about = "Friendly Sushi Roll generating rotations for Splatoon", after_help = OVERRIDE_HELP)] struct Args { - // How many rotations to generate + /// How many rotations to generate #[arg(short, long)] count: Option, - // UNIX timestamp to start the schedule from + /// UNIX timestamp to start the schedule from #[arg(short, long)] start: Option, - // Output file path + /// Output file path #[arg(short, long, default_value = "VSSetting.byaml")] output: String, - // Generate YAML in addition to the BYAML + /// Generate YAML in addition to the BYAML #[arg(long)] yml: bool, - // Set DisconnectByMemoryHash to true + /// Set DisconnectByMemoryHash to true #[arg(long)] hash_kick: bool, } +const VALID_RULES: &[&str] = &["cVar", "cVgl", "cVlf"]; + +fn parse_map_ids(raw: &str, flag: &str) -> Vec { + let parts: Vec<&str> = raw.split(',').collect(); + if parts.len() != 2 { + eprintln!( + "error: {} requires exactly 2 comma-separated map IDs, got '{}'", + flag, raw + ); + exit(1); + } + let mut ids: Vec = Vec::with_capacity(2); + for p in &parts { + match p.trim().parse::() { + Ok(n) => ids.push(n), + Err(_) => { + eprintln!("error: {} contains invalid map ID '{}'", flag, p.trim()); + exit(1); + } + } + } + ids +} + +fn split_eq_value(arg: &str, flag_name: &str) -> Option { + let prefix = format!("{}=", flag_name); + if arg.starts_with(&prefix) { + Some(arg[prefix.len()..].to_string()) + } else { + None + } +} + +fn require_interval(current: Option) -> usize { + match current { + Some(n) => n, + None => { + eprintln!("error: --regular/--ranked/--rule must follow an --interval flag"); + exit(1); + } + } +} + +fn parse_overrides() -> (HashMap, Vec) { + let raw: Vec = std::env::args().collect(); + let mut overrides: HashMap = HashMap::new(); + let mut remaining: Vec = Vec::with_capacity(raw.len()); + if let Some(prog) = raw.first() { + remaining.push(prog.clone()); + } + + let mut current_interval: Option = None; + let mut i = 1; + while i < raw.len() { + let arg = &raw[i]; + + if arg == "--interval" || split_eq_value(arg, "--interval").is_some() { + let value = if let Some(v) = split_eq_value(arg, "--interval") { + v + } else { + i += 1; + if i >= raw.len() { + eprintln!("error: --interval requires a value"); + exit(1); + } + raw[i].clone() + }; + match value.parse::() { + Ok(n) => current_interval = Some(n), + Err(_) => { + eprintln!( + "error: --interval value '{}' is not a valid positive integer", + value + ); + exit(1); + } + } + } else if arg == "--regular" || split_eq_value(arg, "--regular").is_some() { + let value = if let Some(v) = split_eq_value(arg, "--regular") { + v + } else { + i += 1; + if i >= raw.len() { + eprintln!("error: --regular requires a value"); + exit(1); + } + raw[i].clone() + }; + let interval = require_interval(current_interval); + let ids = parse_map_ids(&value, "--regular"); + overrides.entry(interval).or_default().regular = Some(ids); + } else if arg == "--ranked" || split_eq_value(arg, "--ranked").is_some() { + let value = if let Some(v) = split_eq_value(arg, "--ranked") { + v + } else { + i += 1; + if i >= raw.len() { + eprintln!("error: --ranked requires a value"); + exit(1); + } + raw[i].clone() + }; + let interval = require_interval(current_interval); + let ids = parse_map_ids(&value, "--ranked"); + overrides.entry(interval).or_default().ranked = Some(ids); + } else if arg == "--rule" || split_eq_value(arg, "--rule").is_some() { + let value = if let Some(v) = split_eq_value(arg, "--rule") { + v + } else { + i += 1; + if i >= raw.len() { + eprintln!("error: --rule requires a value"); + exit(1); + } + raw[i].clone() + }; + let interval = require_interval(current_interval); + if !VALID_RULES.contains(&value.as_str()) { + eprintln!( + "error: --rule must be one of {:?}, got '{}'", + VALID_RULES, value + ); + exit(1); + } + overrides.entry(interval).or_default().rule = Some(value); + } else { + remaining.push(arg.clone()); + } + + i += 1; + } + + (overrides, remaining) +} + fn main() -> Result<(), Box> { - let args = Args::parse(); + let (overrides, remaining) = parse_overrides(); + let args = Args::parse_from(remaining); let count = args.count.unwrap_or(DEFAULT); let start_time = args.start.unwrap_or_else(default_timestamp); - let schedule = generator::generate(count, start_time); + let schedule = generator::generate(count, start_time, &overrides); exporter::export(&schedule, &args.output, args.hash_kick)?; if args.yml {