use std::ffi::OsString; use std::path::PathBuf; use hexwork_core::character::{Ancestry, Calling, ALL_ANCESTRIES, ALL_CALLINGS}; use hexwork_core::modes::{self, Mode}; use crate::presentation_settings::{ parse_fov, parse_gamma, PaletteChoice, PresentationLoadError, PresentationOverrides, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ModeConstraint { Story, Ironcartographer, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Operation { Help, Arena, Replay(PathBuf), Recover, Play, } #[derive(Clone, Debug)] pub(crate) struct Cli { pub operation: Operation, pub seed: u64, pub seed_explicit: bool, pub mode: Mode, pub mode_constraint: Option, pub daily: Option<(u32, u32, u32)>, pub data_dir: Option, pub content_root: Option, pub load: Option, pub headless_script: Option, pub export_ansi: Option, pub name: Option, pub ancestry: Option, pub calling: Option, pub trusted_work_limit: Option, pub presentation_overrides: PresentationOverrides, } impl Cli { pub(crate) fn parse(args: impl IntoIterator) -> Result { let mut args = args.into_iter(); let _program = args.next(); let mut cli = Self { operation: Operation::Play, seed: 140, seed_explicit: false, mode: Mode::Classic, mode_constraint: None, daily: None, data_dir: None, content_root: None, load: None, headless_script: None, export_ansi: None, name: None, ancestry: None, calling: None, trusted_work_limit: None, presentation_overrides: PresentationOverrides::default(), }; let mut operation: Option = None; let mut saw_seed = false; let values: Vec = args.collect(); let mut index = 0; while index < values.len() { let flag = values[index] .to_str() .ok_or_else(|| "option name is not valid UTF-8".to_owned())?; index += 1; match flag { "--help" | "-h" => set_operation(&mut operation, Operation::Help)?, "--arena" => set_operation(&mut operation, Operation::Arena)?, "--recover" => set_operation(&mut operation, Operation::Recover)?, "--story" => { if cli.daily.is_some() { return Err("--story conflicts with --daily".into()); } cli.mode = Mode::Story; cli.mode_constraint = Some(ModeConstraint::Story); } "--seed" => { if cli.daily.is_some() { return Err("--seed conflicts with --daily".into()); } let value = next_text(&values, &mut index, flag)?; cli.seed = parse_decimal(value, flag)?; cli.seed_explicit = true; saw_seed = true; } "--daily" => { if cli.mode_constraint == Some(ModeConstraint::Story) { return Err("--daily conflicts with --story".into()); } if saw_seed { return Err("--daily conflicts with --seed".into()); } let value = next_text(&values, &mut index, flag)?; let date = parse_daily(value)?; cli.seed = modes::daily_seed(date.0, date.1, date.2); cli.seed_explicit = true; cli.mode = Mode::Ironcartographer; cli.mode_constraint = Some(ModeConstraint::Ironcartographer); cli.daily = Some(date); } "--data-dir" => cli.data_dir = Some(next_path(&values, &mut index, flag)?), "--content-root" => cli.content_root = Some(next_path(&values, &mut index, flag)?), "--load" => cli.load = Some(next_text(&values, &mut index, flag)?.to_owned()), "--headless-script" => { cli.headless_script = Some(next_path(&values, &mut index, flag)?) } "--replay" => { let path = next_path(&values, &mut index, flag)?; set_operation(&mut operation, Operation::Replay(path))?; } "--export-ansi" => cli.export_ansi = Some(next_path(&values, &mut index, flag)?), "--name" => { let value = next_text(&values, &mut index, flag)?; if value.trim().is_empty() { return Err("--name must not be blank".into()); } cli.name = Some(value.to_owned()); } "--ancestry" => { let value = parse_index(next_text(&values, &mut index, flag)?, flag, 4)?; cli.ancestry = Some(ALL_ANCESTRIES[value]); } "--calling" => { let value = parse_index(next_text(&values, &mut index, flag)?, flag, 6)?; cli.calling = Some(ALL_CALLINGS[value]); } "--trusted-work-limit" => { let value = parse_decimal(next_text(&values, &mut index, flag)?, flag)?; if value == 0 { return Err("--trusted-work-limit must be nonzero".into()); } cli.trusted_work_limit = Some(value); } "--gamma" => { if cli.presentation_overrides.gamma.is_some() { return Err("duplicate --gamma".into()); } let value = next_text(&values, &mut index, flag)?; cli.presentation_overrides.gamma = Some(parse_gamma(value).map_err(presentation_value_error)?); } "--fov" => { if cli.presentation_overrides.fov_deg.is_some() { return Err("duplicate --fov".into()); } let value = next_text(&values, &mut index, flag)?; cli.presentation_overrides.fov_deg = Some(parse_fov(value).map_err(presentation_value_error)?); } "--palette" => { if cli.presentation_overrides.palette.is_some() { return Err("duplicate --palette".into()); } let value = next_text(&values, &mut index, flag)?; cli.presentation_overrides.palette = Some(PaletteChoice::parse(value).map_err(presentation_value_error)?); } _ if flag.starts_with('-') => return Err(format!("unknown option: {flag}")), _ => return Err(format!("unexpected positional argument: {flag}")), } } cli.operation = operation.unwrap_or(Operation::Play); cli.validate()?; Ok(cli) } fn validate(&self) -> Result<(), String> { if self.presentation_overrides.is_requested() && (!matches!(self.operation, Operation::Play) || self.headless_script.is_some()) { return Err("--gamma/--fov/--palette are accepted only for interactive play".into()); } match &self.operation { Operation::Help => { if self.has_non_help_options() { return Err("--help cannot be combined with operational options".into()); } } Operation::Arena => { if self.load.is_some() || self.headless_script.is_some() || self.export_ansi.is_some() || self.name.is_some() || self.ancestry.is_some() || self.calling.is_some() || self.data_dir.is_some() || self.content_root.is_some() || self.trusted_work_limit.is_some() || self.daily.is_some() || self.mode_constraint.is_some() { return Err("--arena accepts only --seed".into()); } } Operation::Replay(_) => { if self.load.is_some() || self.headless_script.is_some() || self.name.is_some() || self.ancestry.is_some() || self.calling.is_some() || self.data_dir.is_some() { return Err("--replay conflicts with managed/new-run options".into()); } } Operation::Recover => { if self.load.is_some() || self.headless_script.is_some() || self.export_ansi.is_some() || self.name.is_some() || self.ancestry.is_some() || self.calling.is_some() || self.seed_explicit || self.daily.is_some() || self.mode_constraint.is_some() { return Err( "--recover accepts only root options and --trusted-work-limit".into(), ); } } Operation::Play => { if self.export_ansi.is_some() { return Err("--export-ansi requires --replay".into()); } if self.load.is_some() && (self.name.is_some() || self.ancestry.is_some() || self.calling.is_some()) { return Err("--name/--ancestry/--calling are new-run only".into()); } } } Ok(()) } fn has_non_help_options(&self) -> bool { self.seed_explicit || self.daily.is_some() || self.data_dir.is_some() || self.content_root.is_some() || self.load.is_some() || self.headless_script.is_some() || self.export_ansi.is_some() || self.name.is_some() || self.ancestry.is_some() || self.calling.is_some() || self.trusted_work_limit.is_some() || self.mode_constraint.is_some() || self.presentation_overrides.is_requested() } } fn presentation_value_error(error: PresentationLoadError) -> String { match error { PresentationLoadError::Invalid(detail) => detail, _ => unreachable!("presentation flag parsing only returns Invalid"), } } fn set_operation(slot: &mut Option, value: Operation) -> Result<(), String> { if slot.is_some() { Err("conflicting operation flags".into()) } else { *slot = Some(value); Ok(()) } } fn next_text<'a>(values: &'a [OsString], index: &mut usize, flag: &str) -> Result<&'a str, String> { let value = values .get(*index) .ok_or_else(|| format!("missing argument for {flag}"))?; *index += 1; value .to_str() .ok_or_else(|| format!("argument for {flag} is not valid UTF-8")) } fn next_path(values: &[OsString], index: &mut usize, flag: &str) -> Result { let value = values .get(*index) .ok_or_else(|| format!("missing argument for {flag}"))?; *index += 1; if value.is_empty() { return Err(format!("argument for {flag} must not be empty")); } Ok(PathBuf::from(value)) } fn parse_decimal(value: &str, flag: &str) -> Result { if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { return Err(format!("invalid decimal value for {flag}")); } value .parse() .map_err(|_| format!("value for {flag} overflows u64")) } fn parse_index(value: &str, flag: &str, max: usize) -> Result { let value = parse_decimal(value, flag)?; if value == 0 || value > max as u64 { return Err(format!("{flag} must be in 1..={max}")); } Ok(value as usize - 1) } fn parse_daily(value: &str) -> Result<(u32, u32, u32), String> { let bytes = value.as_bytes(); if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { return Err("--daily requires YYYY-MM-DD".into()); } let year = value[0..4] .parse::() .map_err(|_| "invalid daily year")?; let month = value[5..7] .parse::() .map_err(|_| "invalid daily month")?; let day = value[8..10] .parse::() .map_err(|_| "invalid daily day")?; let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); let max_day = match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 if leap => 29, 2 => 28, _ => return Err("invalid daily month".into()), }; if day == 0 || day > max_day { return Err("invalid daily day".into()); } Ok((year, month, day)) } pub(crate) const HELP: &str = "HEXWORK v0.1.0 - a lantern in the deep\n\nUSAGE: hexwork [OPTIONS]\n\nOPERATIONS:\n --help Show this help\n --arena Read-only deterministic arena proof\n --replay FILE Verify a bounded v4 production replay\n --recover Finish pending managed recovery only\n\nPLAY/ROOTS:\n --data-dir PATH Managed lineage root (default: platform user data)\n --content-root PATH Exact content root (then HEXWORK_CONTENT_ROOT/assets/dev)\n --load RUN_ID Resume one managed run\n --headless-script FILE Run bounded HEXWORK-COMMANDS 1 input\n\nPRESENTATION (INTERACTIVE PLAY ONLY):\n --gamma 0.5..2 Post-shade gamma\n --fov 1..75 Perspective field of view in degrees\n --palette NAME default, protan, deutan, tritan, high-contrast\n\nCONSTRAINTS/SETUP:\n --seed N New seed, or load/replay seed constraint\n --daily YYYY-MM-DD Ironcartographer daily mode/seed constraint\n --story Story mode constraint\n --name NAME New-run display name\n --ancestry 1..4 Mudborn, Cant-born, Pale-touched, Foundling\n --calling 1..6 Ratcatcher, Lampbearer, Tinker, Corpse-tender, Toll-ward, Choir-deserter\n --trusted-work-limit N Trusted replay/recovery full/unattended/elapsed ceiling\n --export-ansi FILE Durable bounded replay event-text ANSI export\n\nKEYS: arrows/WASD/hjkl move · x look · i inventory · Tab orbit · m tactical map\nTRANSFERS: g requests pickup at your feet; - requests dropping the selected carried item.\n Enter confirms only in a separate item prompt; Esc cancels.\nWIELD: inventory Enter inspects; weapon-detail Enter requests Wield; a fresh Enter confirms.\n Wield takes zero ticks. A wielded pike reaches two cells along clear same-floor cardinal lanes.\n Surviving monsters return a strike only within one cell. Loaded keymaps set the actual keys.\n Custom keymaps may leave pickup/drop unbound.\nHEADLESS TRANSFERS: input pickup / input drop (one command per line), then input confirm.\n input escape cancels the prompt; ordinary contextual Confirm behavior is unchanged."; #[cfg(test)] mod tests { use super::*; fn parse(args: &[&str]) -> Result { Cli::parse(args.iter().map(OsString::from)) } #[test] fn strict_values_and_conflicts() { assert!(parse(&["hexwork", "--seed", "bad"]).is_err()); assert!(parse(&["hexwork", "--daily", "2023-02-29"]).is_err()); assert!(parse(&["hexwork", "--story", "--daily", "2024-02-29"]).is_err()); assert!(parse(&[ "hexwork", "--load", "run-00000000000000000001", "--name", "x" ]) .is_err()); assert!(parse(&["hexwork", "--replay", "x", "--data-dir", "y"]).is_err()); } #[test] fn standalone_operations_reject_ignored_setup_exactly() { for args in [ &["hexwork", "--recover", "--seed", "7"][..], &["hexwork", "--recover", "--story"][..], &["hexwork", "--recover", "--daily", "2024-02-29"][..], &["hexwork", "--recover", "--name", "Ignored"][..], &["hexwork", "--recover", "--ancestry", "1"][..], &["hexwork", "--recover", "--calling", "1"][..], ] { assert_eq!( parse(args).unwrap_err(), "--recover accepts only root options and --trusted-work-limit" ); } assert_eq!( parse(&["hexwork", "--recover", "--trusted-work-limit", "9"]) .unwrap() .trusted_work_limit, Some(9) ); assert_eq!( parse(&["hexwork", "--arena", "--seed", "9"]).unwrap().seed, 9 ); for args in [ &["hexwork", "--arena", "--story"][..], &["hexwork", "--arena", "--daily", "2024-02-29"][..], ] { assert_eq!(parse(args).unwrap_err(), "--arena accepts only --seed"); } } #[test] fn cli_presentation_flags_reject_invalid_duplicate_and_noninteractive() { let parsed = parse(&[ "hexwork", "--gamma", "1.5", "--fov", "60", "--palette", "protan", ]) .unwrap(); assert_eq!(parsed.presentation_overrides.gamma, Some(1.5)); assert_eq!(parsed.presentation_overrides.fov_deg, Some(60.0)); assert_eq!( parsed.presentation_overrides.palette, Some(PaletteChoice::Protan) ); for args in [ &["hexwork", "--gamma", "NaN"][..], &["hexwork", "--gamma", "inf"][..], &["hexwork", "--gamma", "0.49"][..], &["hexwork", "--gamma", "2.01"][..], &["hexwork", "--fov", "0"][..], &["hexwork", "--fov", "76"][..], &["hexwork", "--palette", "no-flicker"][..], &["hexwork", "--gamma", "1", "--gamma", "1.5"][..], &["hexwork", "--fov", "60", "--fov", "50"][..], &["hexwork", "--palette", "protan", "--palette", "deutan"][..], ] { assert!(parse(args).is_err(), "accepted invalid args: {args:?}"); } for args in [ &["hexwork", "--help", "--gamma", "1.5"][..], &["hexwork", "--arena", "--fov", "60"][..], &["hexwork", "--replay", "x", "--palette", "protan"][..], &["hexwork", "--recover", "--gamma", "1.5"][..], &["hexwork", "--headless-script", "x", "--palette", "protan"][..], ] { assert_eq!( parse(args).unwrap_err(), "--gamma/--fov/--palette are accepted only for interactive play" ); } } #[test] fn help_describes_explicit_transfer_controls_and_optional_bindings() { assert!(HELP.contains("g requests pickup at your feet")); assert!(HELP.contains("- requests dropping the selected carried item")); assert!(HELP.contains("Enter confirms only in a separate item prompt")); assert!(HELP.contains("weapon-detail Enter requests Wield; a fresh Enter confirms")); assert!(HELP.contains("Wield takes zero ticks")); assert!(HELP.contains("clear same-floor cardinal lanes")); assert!(HELP.contains("Custom keymaps may leave pickup/drop unbound")); assert!(HELP.contains("input pickup / input drop (one command per line)")); assert!(HELP.contains("ordinary contextual Confirm behavior is unchanged")); } #[test] fn canonical_setup_indices() { let cli = parse(&["hexwork", "--ancestry", "4", "--calling", "6"]).unwrap(); assert_eq!(cli.ancestry, Some(Ancestry::Foundling)); assert_eq!(cli.calling, Some(Calling::ChoirDeserter)); assert!(parse(&["hexwork", "--ancestry", "5"]).is_err()); } }