//! MegaMek scenario files (`.mms`), read and written. //! //! There is no published specification for this format. What there is, //! and what this crate follows line for line, is //! `megamek/common/scenario/ScenarioV1.java` and `ScenarioLoader.java` in //! MegaMek 0.51.0 — the version arena's image pins. Where MegaMek is //! surprising, this is surprising the same way, and says so: the server that //! runs the match is the authority, and a parser that read a file more //! sensibly than the server does would be wrong in the one situation that //! matters. //! //! Two formats share the extension. `MMSVersion=1` is the properties-style //! one every scenario a match can be launched into uses; it is read and //! written here in full ([`v1`]). `MMSVersion: 2` is YAML and much larger, and //! is read only as far as its top-level fields ([`v2`]) — enough to identify //! and list one, which is all anything here needs. pub mod document; pub mod v1; pub mod v2; pub use document::Document; pub use v1::ScenarioV1; pub use v2::HeaderV2; /// Which of the two formats a file is written in. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Version { V1, V2, } /// A file read as whichever format it declares. #[derive(Debug, Clone)] pub enum Scenario { V1(ScenarioV1), V2(HeaderV2), } impl Scenario { /// The scenario's title, however the file spells it. pub fn name(&self) -> Option<&str> { match self { Scenario::V1(s) => s.name.as_deref(), Scenario::V2(h) => h.name.as_deref(), } } pub fn description(&self) -> Option<&str> { match self { Scenario::V1(s) => s.description.as_deref(), Scenario::V2(h) => h.description.as_deref(), } } pub fn version(&self) -> Version { match self { Scenario::V1(_) => Version::V1, Scenario::V2(_) => Version::V2, } } } /// Why a file could not be read. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { /// No `MMSVersion` line, or one naming a version that is not 1 or 2. /// MegaMek's own message for this is "The scenario file lacks scenario /// version info!", and it is the same refusal either way. NoVersion, /// A version 1 file that does not parse. V1(v1::ParseError), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::NoVersion => f.write_str("the scenario file lacks scenario version info!"), Error::V1(e) => write!(f, "{e}"), } } } impl std::error::Error for Error {} /// Which version a file declares, by MegaMek's own rule. /// /// `ScenarioLoader.findMmsVersion` scans for the first non-comment line /// matching `^\s*MMSVersion\s*[:=]\s*(\d)` and takes one digit. So `=` and `:` /// are both accepted whichever format the file turns out to be, the line need /// not be first, and a two-digit version would read as its first digit — which /// is upstream's problem to have, not ours to improve on. pub fn detect_version(text: &str) -> Option { for line in text.lines() { if line.trim_start().starts_with('#') { continue; } let rest = line.trim_start(); let Some(rest) = strip_prefix_ignore_ascii_case(rest, "MMSVersion") else { continue; }; let rest = rest.trim_start(); let Some(rest) = rest.strip_prefix([':', '=']) else { continue; }; let rest = rest.trim_start(); return match rest.bytes().next() { Some(b'1') => Some(Version::V1), Some(b'2') => Some(Version::V2), _ => None, }; } None } /// Read a scenario, whichever version it is. pub fn parse(text: &str) -> Result { match detect_version(text) { Some(Version::V1) => ScenarioV1::parse(text).map(Scenario::V1).map_err(Error::V1), Some(Version::V2) => Ok(Scenario::V2(HeaderV2::parse(text))), None => Err(Error::NoVersion), } } /// Java's `Pattern.compile("MMSVersion")` is case-sensitive, so this is too — /// the helper exists to keep the comparison explicit rather than to relax it. fn strip_prefix_ignore_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { value.strip_prefix(prefix) } #[cfg(test)] mod tests { use super::*; #[test] fn detects_both_spellings_of_the_version_line() { assert_eq!(detect_version("MMSVersion=1\n"), Some(Version::V1)); assert_eq!(detect_version("MMSVersion: 2\n"), Some(Version::V2)); assert_eq!(detect_version(" MMSVersion = 1\n"), Some(Version::V1)); } #[test] fn the_version_line_need_not_be_first() { let text = "# a long licence header\n#\nName=x\nMMSVersion=1\n"; assert_eq!(detect_version(text), Some(Version::V1)); } #[test] fn a_commented_out_version_does_not_count() { assert_eq!(detect_version("# MMSVersion=1\n"), None); } #[test] fn no_version_line_is_refused() { assert_eq!(parse("Factions=A,B\n").unwrap_err(), Error::NoVersion); } #[test] fn an_unknown_version_is_refused() { assert_eq!(detect_version("MMSVersion=3\n"), None); } #[test] fn reads_each_version_as_itself() { let v1 = parse("MMSVersion=1\nName=A\nFactions=X\nUnit_X_1=Wasp WSP-1A,P,4,5\n").unwrap(); assert_eq!(v1.version(), Version::V1); assert_eq!(v1.name(), Some("A")); let v2 = parse("MMSVersion: 2\nname: B\n").unwrap(); assert_eq!(v2.version(), Version::V2); assert_eq!(v2.name(), Some("B")); } } /// The factions a scenario seats, in file order, whichever format it is in. /// /// The API needs two things from a scenario file and neither depends on the /// format: who is seated, and in what order — the first faction is the human /// and the rest are Princess. Everything that wanted `ScenarioV1::parse` for /// that should ask here instead, so a scenario written in V2 is not silently /// treated as having no factions at all. pub fn factions(text: &str) -> Vec { match detect_version(text) { Some(Version::V1) => ScenarioV1::parse(text) .map(|s| { s.factions .iter() .map(|f| Faction { name: f.name.clone(), team: f.team, units: f.units.len(), deploy: None, }) .collect() }) .unwrap_or_default(), Some(Version::V2) => v2::ScenarioV2::parse(text) .factions .iter() .map(|f| Faction { name: f.name.clone(), team: f.team, units: f.units.len(), deploy: f.deploy.clone(), }) .collect(), None => Vec::new(), } } /// One seat in a scenario, as the API cares about it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Faction { pub name: String, /// The MegaMek team, which is the side this faction fights on. pub team: Option, /// How many machines it fields. pub units: usize, /// Where on the board it starts - "N", "SW", "CTR". V2 only: a V1 file /// carries the same idea in a companion key this parser does not read. /// /// The site stands each force in its zone when it draws a scenario, and a /// card of a finished match wants the same picture. pub deploy: Option, }