Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
7.6 kB · 225 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226//! 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<Version> { 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<Scenario, Error> { 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<Faction> { 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<i32>, /// 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<String>,}