Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
20 kB · 472 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473use 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<ModeConstraint>, pub daily: Option<(u32, u32, u32)>, pub data_dir: Option<PathBuf>, pub content_root: Option<PathBuf>, pub load: Option<String>, pub headless_script: Option<PathBuf>, pub export_ansi: Option<PathBuf>, pub name: Option<String>, pub ancestry: Option<Ancestry>, pub calling: Option<Calling>, pub trusted_work_limit: Option<u64>, pub presentation_overrides: PresentationOverrides,}
impl Cli { pub(crate) fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, String> { 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<Operation> = None; let mut saw_seed = false; let values: Vec<OsString> = 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<Operation>, 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<PathBuf, String> { 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<u64, String> { 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<usize, String> { 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::<u32>() .map_err(|_| "invalid daily year")?; let month = value[5..7] .parse::<u32>() .map_err(|_| "invalid daily month")?; let day = value[8..10] .parse::<u32>() .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, String> { 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()); }}